diff --git a/FAQ.md b/FAQ.md index 5da37d20..52f50506 100644 --- a/FAQ.md +++ b/FAQ.md @@ -10,9 +10,10 @@ Frequently Asked Questions * Full network access (INTERNET): to send and receive email * View network connections (ACCESS_NETWORK_STATE): to monitor internet connectivity changes * Run at startup (RECEIVE_BOOT_COMPLETED): to start monitoring on device start +* In-app billing (BILLING): to allow in-app purchases +* Foreground service (FOREGROUND_SERVICE): to run a foreground service on Android 9 Pie and later, see also the next question * Optional: read your contacts (READ_CONTACTS): to autocomplete addresses -* ... (BILLING): to offer in-app purchases -* ... (FOREGROUND_SERVICE): to run a foreground service on Android 9 Pie and later, see also the next question +* Optional: find accounts on the device (GET_ACCOUNTS): to use [OAuth](https://en.wikipedia.org/wiki/OAuth) instead of passwords **(2) Why is there a permanent notification shown?** diff --git a/app/schemas/eu.faircode.email.DB/5.json b/app/schemas/eu.faircode.email.DB/5.json new file mode 100644 index 00000000..e51cbf47 --- /dev/null +++ b/app/schemas/eu.faircode.email.DB/5.json @@ -0,0 +1,844 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "df70f524a0c744041e89eea7e7052e73", + "entities": [ + { + "tableName": "identity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `email` TEXT NOT NULL, `replyto` TEXT, `account` INTEGER NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `starttls` INTEGER NOT NULL, `user` TEXT NOT NULL, `password` TEXT NOT NULL, `auth_type` INTEGER NOT NULL, `primary` INTEGER NOT NULL, `synchronize` INTEGER NOT NULL, `store_sent` INTEGER NOT NULL, `state` TEXT, `error` TEXT, FOREIGN KEY(`account`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyto", + "columnName": "replyto", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starttls", + "columnName": "starttls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auth_type", + "columnName": "auth_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primary", + "columnName": "primary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronize", + "columnName": "synchronize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "store_sent", + "columnName": "store_sent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_identity_account", + "unique": false, + "columnNames": [ + "account" + ], + "createSql": "CREATE INDEX `index_identity_account` ON `${TABLE_NAME}` (`account`)" + } + ], + "foreignKeys": [ + { + "table": "account", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "account" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "account", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `user` TEXT NOT NULL, `password` TEXT NOT NULL, `auth_type` INTEGER NOT NULL, `primary` INTEGER NOT NULL, `synchronize` INTEGER NOT NULL, `store_sent` INTEGER NOT NULL, `poll_interval` INTEGER NOT NULL, `seen_until` INTEGER, `state` TEXT, `error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auth_type", + "columnName": "auth_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primary", + "columnName": "primary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronize", + "columnName": "synchronize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "store_sent", + "columnName": "store_sent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "poll_interval", + "columnName": "poll_interval", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seen_until", + "columnName": "seen_until", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "folder", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` INTEGER, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `synchronize` INTEGER NOT NULL, `after` INTEGER NOT NULL, `state` TEXT, `error` TEXT, FOREIGN KEY(`account`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronize", + "columnName": "synchronize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "after", + "columnName": "after", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_folder_account_name", + "unique": true, + "columnNames": [ + "account", + "name" + ], + "createSql": "CREATE UNIQUE INDEX `index_folder_account_name` ON `${TABLE_NAME}` (`account`, `name`)" + }, + { + "name": "index_folder_account", + "unique": false, + "columnNames": [ + "account" + ], + "createSql": "CREATE INDEX `index_folder_account` ON `${TABLE_NAME}` (`account`)" + }, + { + "name": "index_folder_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX `index_folder_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_folder_type", + "unique": false, + "columnNames": [ + "type" + ], + "createSql": "CREATE INDEX `index_folder_type` ON `${TABLE_NAME}` (`type`)" + } + ], + "foreignKeys": [ + { + "table": "account", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "account" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "message", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `account` INTEGER, `folder` INTEGER NOT NULL, `identity` INTEGER, `replying` INTEGER, `uid` INTEGER, `msgid` TEXT, `references` TEXT, `inreplyto` TEXT, `thread` TEXT, `from` TEXT, `to` TEXT, `cc` TEXT, `bcc` TEXT, `reply` TEXT, `subject` TEXT, `sent` INTEGER, `received` INTEGER NOT NULL, `stored` INTEGER NOT NULL, `seen` INTEGER NOT NULL, `ui_seen` INTEGER NOT NULL, `ui_hide` INTEGER NOT NULL, `error` TEXT, FOREIGN KEY(`account`) REFERENCES `account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`folder`) REFERENCES `folder`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`identity`) REFERENCES `identity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replying`) REFERENCES `message`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folder", + "columnName": "folder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identity", + "columnName": "identity", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "replying", + "columnName": "replying", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "msgid", + "columnName": "msgid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "references", + "columnName": "references", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inreplyto", + "columnName": "inreplyto", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thread", + "columnName": "thread", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "from", + "columnName": "from", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cc", + "columnName": "cc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bcc", + "columnName": "bcc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reply", + "columnName": "reply", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sent", + "columnName": "sent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "received", + "columnName": "received", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stored", + "columnName": "stored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seen", + "columnName": "seen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ui_seen", + "columnName": "ui_seen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ui_hide", + "columnName": "ui_hide", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_message_account", + "unique": false, + "columnNames": [ + "account" + ], + "createSql": "CREATE INDEX `index_message_account` ON `${TABLE_NAME}` (`account`)" + }, + { + "name": "index_message_folder", + "unique": false, + "columnNames": [ + "folder" + ], + "createSql": "CREATE INDEX `index_message_folder` ON `${TABLE_NAME}` (`folder`)" + }, + { + "name": "index_message_identity", + "unique": false, + "columnNames": [ + "identity" + ], + "createSql": "CREATE INDEX `index_message_identity` ON `${TABLE_NAME}` (`identity`)" + }, + { + "name": "index_message_replying", + "unique": false, + "columnNames": [ + "replying" + ], + "createSql": "CREATE INDEX `index_message_replying` ON `${TABLE_NAME}` (`replying`)" + }, + { + "name": "index_message_folder_uid", + "unique": true, + "columnNames": [ + "folder", + "uid" + ], + "createSql": "CREATE UNIQUE INDEX `index_message_folder_uid` ON `${TABLE_NAME}` (`folder`, `uid`)" + }, + { + "name": "index_message_msgid_folder", + "unique": true, + "columnNames": [ + "msgid", + "folder" + ], + "createSql": "CREATE UNIQUE INDEX `index_message_msgid_folder` ON `${TABLE_NAME}` (`msgid`, `folder`)" + }, + { + "name": "index_message_thread", + "unique": false, + "columnNames": [ + "thread" + ], + "createSql": "CREATE INDEX `index_message_thread` ON `${TABLE_NAME}` (`thread`)" + }, + { + "name": "index_message_received", + "unique": false, + "columnNames": [ + "received" + ], + "createSql": "CREATE INDEX `index_message_received` ON `${TABLE_NAME}` (`received`)" + }, + { + "name": "index_message_ui_seen", + "unique": false, + "columnNames": [ + "ui_seen" + ], + "createSql": "CREATE INDEX `index_message_ui_seen` ON `${TABLE_NAME}` (`ui_seen`)" + }, + { + "name": "index_message_ui_hide", + "unique": false, + "columnNames": [ + "ui_hide" + ], + "createSql": "CREATE INDEX `index_message_ui_hide` ON `${TABLE_NAME}` (`ui_hide`)" + } + ], + "foreignKeys": [ + { + "table": "account", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "account" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "folder", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "folder" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identity" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "message", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replying" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "attachment", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `message` INTEGER NOT NULL, `sequence` INTEGER NOT NULL, `name` TEXT, `type` TEXT NOT NULL, `size` INTEGER, `progress` INTEGER, `available` INTEGER NOT NULL, FOREIGN KEY(`message`) REFERENCES `message`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "available", + "columnName": "available", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_attachment_message", + "unique": false, + "columnNames": [ + "message" + ], + "createSql": "CREATE INDEX `index_attachment_message` ON `${TABLE_NAME}` (`message`)" + }, + { + "name": "index_attachment_message_sequence", + "unique": true, + "columnNames": [ + "message", + "sequence" + ], + "createSql": "CREATE UNIQUE INDEX `index_attachment_message_sequence` ON `${TABLE_NAME}` (`message`, `sequence`)" + } + ], + "foreignKeys": [ + { + "table": "message", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "message" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "operation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `folder` INTEGER NOT NULL, `message` INTEGER NOT NULL, `name` TEXT NOT NULL, `args` TEXT NOT NULL, `created` INTEGER NOT NULL, FOREIGN KEY(`folder`) REFERENCES `folder`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`message`) REFERENCES `message`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folder", + "columnName": "folder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "args", + "columnName": "args", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_operation_folder", + "unique": false, + "columnNames": [ + "folder" + ], + "createSql": "CREATE INDEX `index_operation_folder` ON `${TABLE_NAME}` (`folder`)" + }, + { + "name": "index_operation_message", + "unique": false, + "columnNames": [ + "message" + ], + "createSql": "CREATE INDEX `index_operation_message` ON `${TABLE_NAME}` (`message`)" + } + ], + "foreignKeys": [ + { + "table": "folder", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "folder" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "message", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "message" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "answer", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"df70f524a0c744041e89eea7e7052e73\")" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c7ebaaf6..dfee57e3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + diff --git a/app/src/main/java/eu/faircode/email/ActivitySetup.java b/app/src/main/java/eu/faircode/email/ActivitySetup.java index da286450..5f41476c 100644 --- a/app/src/main/java/eu/faircode/email/ActivitySetup.java +++ b/app/src/main/java/eu/faircode/email/ActivitySetup.java @@ -37,6 +37,9 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager; public class ActivitySetup extends ActivityBase implements FragmentManager.OnBackStackChangedListener { boolean hasAccount; + static final int REQUEST_PERMISSION = 1; + static final int REQUEST_CHOOSE_ACCOUNT = 2; + static final String ACTION_EDIT_ACCOUNT = BuildConfig.APPLICATION_ID + ".EDIT_ACCOUNT"; static final String ACTION_EDIT_IDENTITY = BuildConfig.APPLICATION_ID + ".EDIT_IDENTITY"; diff --git a/app/src/main/java/eu/faircode/email/DB.java b/app/src/main/java/eu/faircode/email/DB.java index a8e520c4..523c11ea 100644 --- a/app/src/main/java/eu/faircode/email/DB.java +++ b/app/src/main/java/eu/faircode/email/DB.java @@ -45,7 +45,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase; // https://developer.android.com/topic/libraries/architecture/room.html @Database( - version = 4, + version = 5, entities = { EntityIdentity.class, EntityAccount.class, @@ -135,6 +135,14 @@ public abstract class DB extends RoomDatabase { db.execSQL("CREATE TABLE IF NOT EXISTS `answer` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `text` TEXT NOT NULL)"); } }) + .addMigrations(new Migration(4, 5) { + @Override + public void migrate(SupportSQLiteDatabase db) { + Log.i(Helper.TAG, "DB migration from version " + startVersion + " to " + endVersion); + db.execSQL("ALTER TABLE `account` ADD COLUMN `auth_type` INTEGER NOT NULL DEFAULT 1"); + db.execSQL("ALTER TABLE `identity` ADD COLUMN `auth_type` INTEGER NOT NULL DEFAULT 1"); + } + }) .build(); } diff --git a/app/src/main/java/eu/faircode/email/EntityAccount.java b/app/src/main/java/eu/faircode/email/EntityAccount.java index 5adf6ba6..e356c8d2 100644 --- a/app/src/main/java/eu/faircode/email/EntityAccount.java +++ b/app/src/main/java/eu/faircode/email/EntityAccount.java @@ -43,6 +43,8 @@ public class EntityAccount { @NonNull public String password; @NonNull + public Integer auth_type; + @NonNull public Boolean primary; @NonNull public Boolean synchronize; diff --git a/app/src/main/java/eu/faircode/email/EntityIdentity.java b/app/src/main/java/eu/faircode/email/EntityIdentity.java index 860e47d3..2d6caa91 100644 --- a/app/src/main/java/eu/faircode/email/EntityIdentity.java +++ b/app/src/main/java/eu/faircode/email/EntityIdentity.java @@ -59,6 +59,8 @@ public class EntityIdentity { @NonNull public String password; @NonNull + public Integer auth_type; + @NonNull public Boolean primary; @NonNull public Boolean synchronize; diff --git a/app/src/main/java/eu/faircode/email/FragmentAccount.java b/app/src/main/java/eu/faircode/email/FragmentAccount.java index 0ba12219..20fd54c3 100644 --- a/app/src/main/java/eu/faircode/email/FragmentAccount.java +++ b/app/src/main/java/eu/faircode/email/FragmentAccount.java @@ -19,8 +19,17 @@ package eu.faircode.email; Copyright 2018 by Marcel Bokhorst (M66B) */ +import android.Manifest; +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AccountManagerCallback; +import android.accounts.AccountManagerFuture; +import android.app.Activity; import android.content.Context; import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.text.Html; @@ -53,6 +62,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Properties; import javax.mail.Folder; import javax.mail.MessagingException; @@ -62,14 +72,18 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.constraintlayout.widget.Group; +import androidx.core.content.ContextCompat; import androidx.lifecycle.Observer; +import static android.accounts.AccountManager.newChooseAccountIntent; + public class FragmentAccount extends FragmentEx { private ViewGroup view; private EditText etName; private Spinner spProvider; private EditText etHost; private EditText etPort; + private Button btnAuthorize; private EditText etUser; private TextInputLayout tilPassword; private TextView tvLink; @@ -91,6 +105,18 @@ public class FragmentAccount extends FragmentEx { private Group grpInstructions; private Group grpFolders; + private long id = -1; + private int auth_type = Helper.AUTH_TYPE_PASSWORD; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Get arguments + Bundle args = getArguments(); + id = (args == null ? -1 : args.getLong("id", -1)); + } + @Override @Nullable public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -98,14 +124,11 @@ public class FragmentAccount extends FragmentEx { view = (ViewGroup) inflater.inflate(R.layout.fragment_account, container, false); - // Get arguments - Bundle args = getArguments(); - final long id = (args == null ? -1 : args.getLong("id", -1)); - // Get controls spProvider = view.findViewById(R.id.spProvider); etName = view.findViewById(R.id.etName); etHost = view.findViewById(R.id.etHost); + btnAuthorize = view.findViewById(R.id.btnAuthorize); etPort = view.findViewById(R.id.etPort); etUser = view.findViewById(R.id.etUser); tilPassword = view.findViewById(R.id.tilPassword); @@ -143,12 +166,23 @@ public class FragmentAccount extends FragmentEx { tvLink.setText(Html.fromHtml("" + provider.link + "")); grpInstructions.setVisibility(provider.link == null ? View.GONE : View.VISIBLE); - if (provider.imap_port != 0) { + if (provider.imap_port > 0) { etName.setText(provider.name); etHost.setText(provider.imap_host); etPort.setText(Integer.toString(provider.imap_port)); - etUser.requestFocus(); + if (provider.type == null) + etUser.requestFocus(); } + + if (provider.type == null) { + btnAuthorize.setVisibility(View.GONE); + if (auth_type != Helper.AUTH_TYPE_PASSWORD) { + auth_type = Helper.AUTH_TYPE_PASSWORD; + etUser.setText(null); + tilPassword.getEditText().setText(null); + } + } else + btnAuthorize.setVisibility(View.VISIBLE); } @Override @@ -156,6 +190,19 @@ public class FragmentAccount extends FragmentEx { } }); + btnAuthorize.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String permission = Manifest.permission.GET_ACCOUNTS; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && + ContextCompat.checkSelfPermission(getContext(), permission) != PackageManager.PERMISSION_GRANTED) { + Log.i(Helper.TAG, "Requesting " + permission); + requestPermissions(new String[]{permission}, ActivitySetup.REQUEST_PERMISSION); + } else + selectAccount(); + } + }); + cbSynchronize.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton compoundButton, boolean checked) { @@ -181,6 +228,7 @@ public class FragmentAccount extends FragmentEx { args.putString("port", etPort.getText().toString()); args.putString("user", etUser.getText().toString()); args.putString("password", tilPassword.getEditText().getText().toString()); + args.putInt("auth_type", auth_type); args.putBoolean("synchronize", cbSynchronize.isChecked()); args.putBoolean("primary", cbPrimary.isChecked()); @@ -192,6 +240,7 @@ public class FragmentAccount extends FragmentEx { String port = args.getString("port"); String user = args.getString("user"); String password = args.getString("password"); + int auth_type = args.getInt("auth_type"); if (TextUtils.isEmpty(host)) throw new Throwable(getContext().getString(R.string.title_no_host)); @@ -204,7 +253,10 @@ public class FragmentAccount extends FragmentEx { // Check IMAP server / get folders List folders = new ArrayList<>(); - Session isession = Session.getInstance(MessageHelper.getSessionProperties(), null); + Properties props = MessageHelper.getSessionProperties(auth_type); + + Session isession = Session.getInstance(props, null); + isession.setDebug(true); IMAPStore istore = null; try { istore = (IMAPStore) isession.getStore("imaps"); @@ -379,6 +431,7 @@ public class FragmentAccount extends FragmentEx { args.putString("port", etPort.getText().toString()); args.putString("user", etUser.getText().toString()); args.putString("password", tilPassword.getEditText().getText().toString()); + args.putInt("auth_type", auth_type); args.putBoolean("synchronize", cbSynchronize.isChecked()); args.putBoolean("primary", cbPrimary.isChecked()); args.putString("poll_interval", etInterval.getText().toString()); @@ -396,6 +449,7 @@ public class FragmentAccount extends FragmentEx { String port = args.getString("port"); String user = args.getString("user"); String password = args.getString("password"); + int auth_type = args.getInt("auth_type"); boolean synchronize = args.getBoolean("synchronize"); boolean primary = args.getBoolean("primary"); String poll_interval = args.getString("poll_interval"); @@ -421,7 +475,7 @@ public class FragmentAccount extends FragmentEx { // Check IMAP server if (synchronize) { - Session isession = Session.getInstance(MessageHelper.getSessionProperties(), null); + Session isession = Session.getInstance(MessageHelper.getSessionProperties(auth_type), null); IMAPStore istore = null; try { istore = (IMAPStore) isession.getStore("imaps"); @@ -451,6 +505,7 @@ public class FragmentAccount extends FragmentEx { account.port = Integer.parseInt(port); account.user = user; account.password = password; + account.auth_type = auth_type; account.synchronize = synchronize; account.primary = (account.synchronize && primary); account.store_sent = false; @@ -582,6 +637,7 @@ public class FragmentAccount extends FragmentEx { // Initialize Helper.setViewsEnabled(view, false); + btnAuthorize.setVisibility(View.GONE); tilPassword.setPasswordVisibilityToggleEnabled(id < 0); tvLink.setMovementMethod(LinkMovementMethod.getInstance()); btnCheck.setEnabled(false); @@ -599,6 +655,7 @@ public class FragmentAccount extends FragmentEx { public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putInt("provider", spProvider.getSelectedItemPosition()); + outState.putInt("auth_type", auth_type); outState.putString("password", tilPassword.getEditText().getText().toString()); outState.putInt("instructions", grpInstructions.getVisibility()); } @@ -607,10 +664,6 @@ public class FragmentAccount extends FragmentEx { public void onActivityCreated(@Nullable final Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); - // Get arguments - Bundle args = getArguments(); - long id = (args == null ? -1 : args.getLong("id", -1)); - // Observe DB.getInstance(getContext()).account().liveAccount(id).observe(getViewLifecycleOwner(), new Observer() { boolean once = false; @@ -640,6 +693,7 @@ public class FragmentAccount extends FragmentEx { etInterval.setText(account == null ? "9" : Integer.toString(account.poll_interval)); } else { int provider = savedInstanceState.getInt("provider"); + auth_type = savedInstanceState.getInt("auth_type"); spProvider.setTag(provider); spProvider.setSelection(provider); tilPassword.getEditText().setText(savedInstanceState.getString("password")); @@ -648,6 +702,7 @@ public class FragmentAccount extends FragmentEx { Helper.setViewsEnabled(view, true); + btnAuthorize.setVisibility(auth_type == Helper.AUTH_TYPE_PASSWORD ? View.GONE : View.VISIBLE); cbPrimary.setEnabled(cbSynchronize.isChecked()); btnCheck.setVisibility(cbSynchronize.isChecked() ? View.VISIBLE : View.GONE); @@ -660,4 +715,70 @@ public class FragmentAccount extends FragmentEx { } }); } + + void selectAccount() { + Log.i(Helper.TAG, "Select account"); + Provider provider = (Provider) spProvider.getSelectedItem(); + if (provider.type != null) + startActivityForResult(newChooseAccountIntent( + null, + null, + new String[]{provider.type}, + null, + null, + null, + null), ActivitySetup.REQUEST_CHOOSE_ACCOUNT); + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + if (requestCode == ActivitySetup.REQUEST_PERMISSION) + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) + selectAccount(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + Log.i(Helper.TAG, "Activity result request=" + requestCode + " result=" + resultCode + " data=" + data); + if (resultCode == Activity.RESULT_OK) + if (requestCode == ActivitySetup.REQUEST_CHOOSE_ACCOUNT) { + String name = data.getStringExtra(AccountManager.KEY_ACCOUNT_NAME); + String type = data.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE); + + String authTokenType = null; + if ("com.google".equals(type)) + authTokenType = "oauth2:https://mail.google.com/"; + + AccountManager am = AccountManager.get(getContext()); + Account[] accounts = am.getAccountsByType(type); + Log.i(Helper.TAG, "Accounts=" + accounts.length); + for (final Account account : accounts) + if (name.equals(account.name)) { + am.getAuthToken( + account, + authTokenType, + new Bundle(), + getActivity(), + new AccountManagerCallback() { + @Override + public void run(AccountManagerFuture future) { + try { + Bundle bundle = future.getResult(); + String token = bundle.getString(AccountManager.KEY_AUTHTOKEN); + Log.i(Helper.TAG, "Got token"); + + auth_type = Helper.AUTH_TYPE_GMAIL; + etUser.setText(account.name); + tilPassword.getEditText().setText(token); + } catch (Throwable ex) { + Log.e(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); + Toast.makeText(getContext(), Helper.formatThrowable(ex), Toast.LENGTH_LONG).show(); + } + } + }, + null); + break; + } + } + } } diff --git a/app/src/main/java/eu/faircode/email/FragmentCompose.java b/app/src/main/java/eu/faircode/email/FragmentCompose.java index d4190a25..f565b459 100644 --- a/app/src/main/java/eu/faircode/email/FragmentCompose.java +++ b/app/src/main/java/eu/faircode/email/FragmentCompose.java @@ -804,7 +804,7 @@ public class FragmentCompose extends FragmentEx { text = db.answer().getAnswer(answer).text; draft.subject = context.getString(R.string.title_subject_reply, ref.subject); body = String.format("%s

%s %s:

%s", - Html.escapeHtml(text).replaceAll("\\r?\\n", "
"), + text.replaceAll("\\r?\\n", "
"), Html.escapeHtml(new Date().toString()), Html.escapeHtml(MessageHelper.getFormattedAddresses(draft.to, true)), HtmlHelper.sanitize(context, ref.read(context), true)); diff --git a/app/src/main/java/eu/faircode/email/FragmentIdentity.java b/app/src/main/java/eu/faircode/email/FragmentIdentity.java index 7a459e9a..1f87de27 100644 --- a/app/src/main/java/eu/faircode/email/FragmentIdentity.java +++ b/app/src/main/java/eu/faircode/email/FragmentIdentity.java @@ -79,6 +79,17 @@ public class FragmentIdentity extends FragmentEx { private ProgressBar pbWait; private Group grpInstructions; + private long id = -1; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Get arguments + Bundle args = getArguments(); + id = (args == null ? -1 : args.getLong("id", -1)); + } + @Override @Nullable public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -86,10 +97,6 @@ public class FragmentIdentity extends FragmentEx { view = (ViewGroup) inflater.inflate(R.layout.fragment_identity, container, false); - // Get arguments - Bundle args = getArguments(); - final long id = (args == null ? -1 : args.getLong("id", -1)); - // Get controls etName = view.findViewById(R.id.etName); etEmail = view.findViewById(R.id.etEmail); @@ -214,6 +221,7 @@ public class FragmentIdentity extends FragmentEx { args.putString("email", etEmail.getText().toString()); args.putString("replyto", etReplyTo.getText().toString()); args.putLong("account", account == null ? -1 : account.id); + args.putInt("auth_type", account == null ? Helper.AUTH_TYPE_PASSWORD : account.auth_type); args.putString("host", etHost.getText().toString()); args.putBoolean("starttls", cbStartTls.isChecked()); args.putString("port", etPort.getText().toString()); @@ -236,6 +244,7 @@ public class FragmentIdentity extends FragmentEx { String port = args.getString("port"); String user = args.getString("user"); String password = args.getString("password"); + int auth_type = args.getInt("auth_type"); boolean synchronize = args.getBoolean("synchronize"); boolean primary = args.getBoolean("primary"); boolean store_sent = args.getBoolean("store_sent"); @@ -260,7 +269,7 @@ public class FragmentIdentity extends FragmentEx { // Check SMTP server if (synchronize) { - Properties props = MessageHelper.getSessionProperties(); + Properties props = MessageHelper.getSessionProperties(auth_type); Session isession = Session.getInstance(props, null); Transport itransport = isession.getTransport(starttls ? "smtp" : "smtps"); try { @@ -287,6 +296,7 @@ public class FragmentIdentity extends FragmentEx { identity.starttls = starttls; identity.user = user; identity.password = password; + identity.auth_type = auth_type; identity.synchronize = synchronize; identity.primary = (identity.synchronize && primary); identity.store_sent = store_sent; @@ -394,10 +404,6 @@ public class FragmentIdentity extends FragmentEx { public void onActivityCreated(@Nullable final Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); - // Get arguments - Bundle args = getArguments(); - long id = (args == null ? -1 : args.getLong("id", -1)); - final DB db = DB.getInstance(getContext()); // Observe identity @@ -447,7 +453,7 @@ public class FragmentIdentity extends FragmentEx { EntityAccount unselected = new EntityAccount(); unselected.id = -1L; - unselected.name = ""; + unselected.name = getString(R.string.title_select); unselected.primary = false; accounts.add(0, unselected); diff --git a/app/src/main/java/eu/faircode/email/Helper.java b/app/src/main/java/eu/faircode/email/Helper.java index 0e106335..3792df3c 100644 --- a/app/src/main/java/eu/faircode/email/Helper.java +++ b/app/src/main/java/eu/faircode/email/Helper.java @@ -52,6 +52,9 @@ import javax.mail.internet.InternetAddress; public class Helper { static final String TAG = "fairemail"; + static final int AUTH_TYPE_PASSWORD = 1; + static final int AUTH_TYPE_GMAIL = 2; + static int resolveColor(Context context, int attr) { int[] attrs = new int[]{attr}; TypedArray a = context.getTheme().obtainStyledAttributes(attrs); diff --git a/app/src/main/java/eu/faircode/email/MessageHelper.java b/app/src/main/java/eu/faircode/email/MessageHelper.java index 857c316c..377f6710 100644 --- a/app/src/main/java/eu/faircode/email/MessageHelper.java +++ b/app/src/main/java/eu/faircode/email/MessageHelper.java @@ -57,7 +57,7 @@ public class MessageHelper { private MimeMessage imessage; private String raw = null; - static Properties getSessionProperties() { + static Properties getSessionProperties(int auth_type) { Properties props = new Properties(); // https://javaee.github.io/javamail/docs/api/com/sun/mail/imap/package-summary.html#properties @@ -97,6 +97,12 @@ public class MessageHelper { props.put("mail.mime.address.strict", "false"); props.put("mail.mime.decodetext.strict", "false"); + // https://javaee.github.io/javamail/OAuth2 + if (auth_type == Helper.AUTH_TYPE_GMAIL) { + props.put("mail.imaps.auth.mechanisms", "XOAUTH2"); + props.put("mail.smtps.auth.mechanisms", "XOAUTH2"); + } + return props; } diff --git a/app/src/main/java/eu/faircode/email/Provider.java b/app/src/main/java/eu/faircode/email/Provider.java index 59703155..aeceedd4 100644 --- a/app/src/main/java/eu/faircode/email/Provider.java +++ b/app/src/main/java/eu/faircode/email/Provider.java @@ -35,6 +35,7 @@ import java.util.Locale; public class Provider { public String name; public String link; + public String type; public String imap_host; public int imap_port; public String smtp_host; @@ -62,6 +63,7 @@ public class Provider { provider = new Provider(); provider.name = xml.getAttributeValue(null, "name"); provider.link = xml.getAttributeValue(null, "link"); + provider.type = xml.getAttributeValue(null, "type"); } else if ("imap".equals(xml.getName())) { provider.imap_host = xml.getAttributeValue(null, "host"); provider.imap_port = xml.getAttributeIntValue(null, "port", 0); diff --git a/app/src/main/java/eu/faircode/email/SearchDataSource.java b/app/src/main/java/eu/faircode/email/SearchDataSource.java index f836e390..9c3f82e5 100644 --- a/app/src/main/java/eu/faircode/email/SearchDataSource.java +++ b/app/src/main/java/eu/faircode/email/SearchDataSource.java @@ -117,7 +117,7 @@ public class SearchDataSource extends PositionalDataSource imple folder = db.folder().getFolder(fid); account = db.account().getAccount(folder.account); - Properties props = MessageHelper.getSessionProperties(); + Properties props = MessageHelper.getSessionProperties(account.auth_type); Session isession = Session.getInstance(props, null); Log.i(Helper.TAG, "SDS connecting account=" + account.name); diff --git a/app/src/main/java/eu/faircode/email/ServiceSynchronize.java b/app/src/main/java/eu/faircode/email/ServiceSynchronize.java index 7c0be6f9..7d4dde4d 100644 --- a/app/src/main/java/eu/faircode/email/ServiceSynchronize.java +++ b/app/src/main/java/eu/faircode/email/ServiceSynchronize.java @@ -73,6 +73,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import javax.mail.Address; +import javax.mail.AuthenticationFailedException; import javax.mail.FetchProfile; import javax.mail.Flags; import javax.mail.Folder; @@ -390,7 +391,7 @@ public class ServiceSynchronize extends LifecycleService { if (debug) System.setProperty("mail.socket.debug", "true"); - Properties props = MessageHelper.getSessionProperties(); + Properties props = MessageHelper.getSessionProperties(account.auth_type); final Session isession = Session.getInstance(props, null); isession.setDebug(debug); // adb -t 1 logcat | grep "fairemail\|System.out" @@ -455,6 +456,8 @@ public class ServiceSynchronize extends LifecycleService { // Initiate connection Log.i(Helper.TAG, account.name + " connect"); + for (EntityFolder folder : db.folder().getFolders(account.id)) + db.folder().setFolderState(folder.id, null); db.account().setAccountState(account.id, "connecting"); istore.connect(account.host, account.port, account.user, account.password); @@ -470,9 +473,6 @@ public class ServiceSynchronize extends LifecycleService { throw new IllegalStateException("synchronize folders", ex); } - for (EntityFolder folder : db.folder().getFolders(account.id)) - db.folder().setFolderState(folder.id, null); - // Synchronize folders for (final EntityFolder folder : db.folder().getFolders(account.id, true)) { Log.i(Helper.TAG, account.name + " sync folder " + folder.name); @@ -752,6 +752,9 @@ public class ServiceSynchronize extends LifecycleService { reportError(account.name, null, ex); db.account().setAccountError(account.id, Helper.formatThrowable(ex)); + + if (ex instanceof AuthenticationFailedException) + break; } finally { // Close store Log.i(Helper.TAG, account.name + " closing"); @@ -840,7 +843,7 @@ public class ServiceSynchronize extends LifecycleService { doDelete(folder, ifolder, message, jargs, db); else if (EntityOperation.SEND.equals(op.name)) - doSend(isession, message, db); + doSend(message, db); else if (EntityOperation.ATTACHMENT.equals(op.name)) doAttachment(folder, op, ifolder, message, jargs, db); @@ -960,23 +963,27 @@ public class ServiceSynchronize extends LifecycleService { db.message().deleteMessage(message.id); } - private void doSend(Session isession, EntityMessage message, DB db) throws MessagingException, IOException { + private void doSend(EntityMessage message, DB db) throws MessagingException, IOException { // Send message EntityIdentity ident = db.identity().getIdentity(message.identity); - EntityMessage reply = (message.replying == null ? null : db.message().getMessage(message.replying)); - List attachments = db.attachment().getAttachments(message.id); - if (!ident.synchronize) { // Message will remain in outbox return; } + // Create session + Properties props = MessageHelper.getSessionProperties(ident.auth_type); + final Session isession = Session.getInstance(props, null); + // Create message MimeMessage imessage; + EntityMessage reply = (message.replying == null ? null : db.message().getMessage(message.replying)); + List attachments = db.attachment().getAttachments(message.id); if (reply == null) imessage = MessageHelper.from(this, message, attachments, isession); else imessage = MessageHelper.from(this, message, reply, attachments, isession); + if (ident.replyto != null) imessage.setReplyTo(new Address[]{new InternetAddress(ident.replyto)}); @@ -1564,16 +1571,12 @@ public class ServiceSynchronize extends LifecycleService { public void onReceive(Context context, Intent intent) { Log.v(Helper.TAG, outbox.name + " run operations"); - // Create session - Properties props = MessageHelper.getSessionProperties(); - final Session isession = Session.getInstance(props, null); - executor.submit(new Runnable() { @Override public void run() { try { Log.i(Helper.TAG, outbox.name + " start operations"); - processOperations(outbox, isession, null, null); + processOperations(outbox, null, null, null); } catch (Throwable ex) { Log.e(Helper.TAG, outbox.name + " " + ex + "\n" + Log.getStackTraceString(ex)); reportError(null, outbox.name, ex); diff --git a/app/src/main/res/layout/fragment_account.xml b/app/src/main/res/layout/fragment_account.xml index 44830102..9d1e331f 100644 --- a/app/src/main/res/layout/fragment_account.xml +++ b/app/src/main/res/layout/fragment_account.xml @@ -108,6 +108,15 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tvPort" /> +