diff --git a/app/build.gradle b/app/build.gradle index df3b6765f..82f1f2dce 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,6 +4,14 @@ apply plugin: 'kotlin-kapt' android { + signingConfigs { + debug { + storeFile file('/home/felix/.keystore_android/keystore.jks') + storePassword 'android' + keyPassword 'android' + keyAlias 'alias_name' + } + } dexOptions { maxProcessCount 4 javaMaxHeapSize "2g" @@ -185,6 +193,9 @@ dependencies { // Resolves DuplicatePlatformClasses lint error exclude group: 'org.apache.httpcomponents', module: 'httpclient' } + + // Encryption + implementation "org.bouncycastle:bcpg-jdk15on:1.65" } repositories { diff --git a/app/premium/release/output.json b/app/premium/release/output.json new file mode 100644 index 000000000..699acfa31 --- /dev/null +++ b/app/premium/release/output.json @@ -0,0 +1 @@ +[{"outputType":{"type":"APK"},"apkData":{"type":"MAIN","splits":[],"versionCode":152,"versionName":"1.8.3","enabled":true,"outputFile":"app-premium-release.apk","fullName":"premiumRelease","baseName":"premium-release","dirName":""},"path":"app-premium-release.apk","properties":{}}] \ No newline at end of file diff --git a/app/schemas/com.orgzly.android.db.OrgzlyDatabase/156.json b/app/schemas/com.orgzly.android.db.OrgzlyDatabase/156.json new file mode 100644 index 000000000..1f6a24b94 --- /dev/null +++ b/app/schemas/com.orgzly.android.db.OrgzlyDatabase/156.json @@ -0,0 +1,1404 @@ +{ + "formatVersion": 1, + "database": { + "version": 156, + "identityHash": "13375dea4eb48e5f8577f47385db42d6", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `title` TEXT, `mtime` INTEGER, `is_dummy` INTEGER NOT NULL, `is_deleted` INTEGER, `preface` TEXT, `is_indented` INTEGER, `used_encoding` TEXT, `detected_encoding` TEXT, `selected_encoding` TEXT, `sync_status` TEXT, `is_modified` INTEGER NOT NULL, `last_action_type` TEXT, `last_action_message` TEXT, `last_action_timestamp` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isDummy", + "columnName": "is_dummy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "preface", + "columnName": "preface", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isIndented", + "columnName": "is_indented", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "usedEncoding", + "columnName": "used_encoding", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "detectedEncoding", + "columnName": "detected_encoding", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "selectedEncoding", + "columnName": "selected_encoding", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "syncStatus", + "columnName": "sync_status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isModified", + "columnName": "is_modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAction.type", + "columnName": "last_action_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastAction.message", + "columnName": "last_action_message", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastAction.timestamp", + "columnName": "last_action_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_books_name", + "unique": true, + "columnNames": [ + "name" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_books_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "book_encryptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`book_id` INTEGER NOT NULL, `passphrase` TEXT NOT NULL, PRIMARY KEY(`book_id`), FOREIGN KEY(`book_id`) REFERENCES `books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookId", + "columnName": "book_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "passphrase", + "columnName": "passphrase", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "book_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "book_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "book_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`book_id` INTEGER NOT NULL, `repo_id` INTEGER NOT NULL, PRIMARY KEY(`book_id`), FOREIGN KEY(`book_id`) REFERENCES `books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repo_id`) REFERENCES `repos`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookId", + "columnName": "book_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repoId", + "columnName": "repo_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "book_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_links_repo_id", + "unique": false, + "columnNames": [ + "repo_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_links_repo_id` ON `${TABLE_NAME}` (`repo_id`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "book_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "repos", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repo_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "book_syncs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`book_id` INTEGER NOT NULL, `versioned_rook_id` INTEGER NOT NULL, PRIMARY KEY(`book_id`), FOREIGN KEY(`book_id`) REFERENCES `books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`versioned_rook_id`) REFERENCES `versioned_rooks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookId", + "columnName": "book_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionedRookId", + "columnName": "versioned_rook_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "book_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_book_syncs_versioned_rook_id", + "unique": false, + "columnNames": [ + "versioned_rook_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_syncs_versioned_rook_id` ON `${TABLE_NAME}` (`versioned_rook_id`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "book_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "versioned_rooks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "versioned_rook_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "db_repo_books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `repo_url` TEXT NOT NULL, `url` TEXT NOT NULL, `revision` TEXT NOT NULL, `mtime` INTEGER NOT NULL, `content` TEXT NOT NULL, `created_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repoUrl", + "columnName": "repo_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_db_repo_books_repo_url_url", + "unique": true, + "columnNames": [ + "repo_url", + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_db_repo_books_repo_url_url` ON `${TABLE_NAME}` (`repo_url`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_cut` INTEGER NOT NULL, `created_at` INTEGER, `title` TEXT NOT NULL, `tags` TEXT, `state` TEXT, `priority` TEXT, `content` TEXT, `content_line_count` INTEGER NOT NULL, `scheduled_range_id` INTEGER, `deadline_range_id` INTEGER, `closed_range_id` INTEGER, `clock_range_id` INTEGER, `book_id` INTEGER NOT NULL, `lft` INTEGER NOT NULL, `rgt` INTEGER NOT NULL, `level` INTEGER NOT NULL, `parent_id` INTEGER NOT NULL, `folded_under_id` INTEGER NOT NULL, `is_folded` INTEGER NOT NULL, `descendants_count` INTEGER NOT NULL, FOREIGN KEY(`book_id`) REFERENCES `books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`scheduled_range_id`) REFERENCES `org_ranges`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`deadline_range_id`) REFERENCES `org_ranges`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`closed_range_id`) REFERENCES `org_ranges`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCut", + "columnName": "is_cut", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentLineCount", + "columnName": "content_line_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledRangeId", + "columnName": "scheduled_range_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deadlineRangeId", + "columnName": "deadline_range_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "closedRangeId", + "columnName": "closed_range_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "clockRangeId", + "columnName": "clock_range_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position.bookId", + "columnName": "book_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position.lft", + "columnName": "lft", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position.rgt", + "columnName": "rgt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position.level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position.parentId", + "columnName": "parent_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position.foldedUnderId", + "columnName": "folded_under_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position.isFolded", + "columnName": "is_folded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position.descendantsCount", + "columnName": "descendants_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_notes_title", + "unique": false, + "columnNames": [ + "title" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_title` ON `${TABLE_NAME}` (`title`)" + }, + { + "name": "index_notes_tags", + "unique": false, + "columnNames": [ + "tags" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_tags` ON `${TABLE_NAME}` (`tags`)" + }, + { + "name": "index_notes_content", + "unique": false, + "columnNames": [ + "content" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_content` ON `${TABLE_NAME}` (`content`)" + }, + { + "name": "index_notes_book_id", + "unique": false, + "columnNames": [ + "book_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_book_id` ON `${TABLE_NAME}` (`book_id`)" + }, + { + "name": "index_notes_is_cut", + "unique": false, + "columnNames": [ + "is_cut" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_is_cut` ON `${TABLE_NAME}` (`is_cut`)" + }, + { + "name": "index_notes_lft", + "unique": false, + "columnNames": [ + "lft" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_lft` ON `${TABLE_NAME}` (`lft`)" + }, + { + "name": "index_notes_rgt", + "unique": false, + "columnNames": [ + "rgt" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_rgt` ON `${TABLE_NAME}` (`rgt`)" + }, + { + "name": "index_notes_is_folded", + "unique": false, + "columnNames": [ + "is_folded" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_is_folded` ON `${TABLE_NAME}` (`is_folded`)" + }, + { + "name": "index_notes_folded_under_id", + "unique": false, + "columnNames": [ + "folded_under_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_folded_under_id` ON `${TABLE_NAME}` (`folded_under_id`)" + }, + { + "name": "index_notes_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_notes_descendants_count", + "unique": false, + "columnNames": [ + "descendants_count" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_descendants_count` ON `${TABLE_NAME}` (`descendants_count`)" + }, + { + "name": "index_notes_scheduled_range_id", + "unique": false, + "columnNames": [ + "scheduled_range_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_scheduled_range_id` ON `${TABLE_NAME}` (`scheduled_range_id`)" + }, + { + "name": "index_notes_deadline_range_id", + "unique": false, + "columnNames": [ + "deadline_range_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_deadline_range_id` ON `${TABLE_NAME}` (`deadline_range_id`)" + }, + { + "name": "index_notes_closed_range_id", + "unique": false, + "columnNames": [ + "closed_range_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notes_closed_range_id` ON `${TABLE_NAME}` (`closed_range_id`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "book_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "org_ranges", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "scheduled_range_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "org_ranges", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "deadline_range_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "org_ranges", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "closed_range_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "note_ancestors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`note_id` INTEGER NOT NULL, `book_id` INTEGER NOT NULL, `ancestor_note_id` INTEGER NOT NULL, PRIMARY KEY(`book_id`, `note_id`, `ancestor_note_id`), FOREIGN KEY(`book_id`) REFERENCES `books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`note_id`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ancestor_note_id`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "noteId", + "columnName": "note_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "book_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ancestorNoteId", + "columnName": "ancestor_note_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "book_id", + "note_id", + "ancestor_note_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_note_ancestors_book_id", + "unique": false, + "columnNames": [ + "book_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_ancestors_book_id` ON `${TABLE_NAME}` (`book_id`)" + }, + { + "name": "index_note_ancestors_note_id", + "unique": false, + "columnNames": [ + "note_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_ancestors_note_id` ON `${TABLE_NAME}` (`note_id`)" + }, + { + "name": "index_note_ancestors_ancestor_note_id", + "unique": false, + "columnNames": [ + "ancestor_note_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_ancestors_ancestor_note_id` ON `${TABLE_NAME}` (`ancestor_note_id`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "book_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "note_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ancestor_note_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "note_properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`note_id` INTEGER NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`note_id`, `position`), FOREIGN KEY(`note_id`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "noteId", + "columnName": "note_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "note_id", + "position" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_note_properties_note_id", + "unique": false, + "columnNames": [ + "note_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_properties_note_id` ON `${TABLE_NAME}` (`note_id`)" + }, + { + "name": "index_note_properties_position", + "unique": false, + "columnNames": [ + "position" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_properties_position` ON `${TABLE_NAME}` (`position`)" + }, + { + "name": "index_note_properties_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_properties_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_note_properties_value", + "unique": false, + "columnNames": [ + "value" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_properties_value` ON `${TABLE_NAME}` (`value`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "note_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "note_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`note_id` INTEGER NOT NULL, `org_range_id` INTEGER NOT NULL, PRIMARY KEY(`note_id`, `org_range_id`), FOREIGN KEY(`note_id`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`org_range_id`) REFERENCES `org_ranges`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "noteId", + "columnName": "note_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orgRangeId", + "columnName": "org_range_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "note_id", + "org_range_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_note_events_note_id", + "unique": false, + "columnNames": [ + "note_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_events_note_id` ON `${TABLE_NAME}` (`note_id`)" + }, + { + "name": "index_note_events_org_range_id", + "unique": false, + "columnNames": [ + "org_range_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_events_org_range_id` ON `${TABLE_NAME}` (`org_range_id`)" + } + ], + "foreignKeys": [ + { + "table": "notes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "note_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "org_ranges", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "org_range_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "org_ranges", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `string` TEXT NOT NULL, `start_timestamp_id` INTEGER NOT NULL, `end_timestamp_id` INTEGER, `difference` INTEGER, FOREIGN KEY(`start_timestamp_id`) REFERENCES `org_timestamps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`end_timestamp_id`) REFERENCES `org_timestamps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "string", + "columnName": "string", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTimestampId", + "columnName": "start_timestamp_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTimestampId", + "columnName": "end_timestamp_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "difference", + "columnName": "difference", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_org_ranges_string", + "unique": true, + "columnNames": [ + "string" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_org_ranges_string` ON `${TABLE_NAME}` (`string`)" + }, + { + "name": "index_org_ranges_start_timestamp_id", + "unique": false, + "columnNames": [ + "start_timestamp_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_org_ranges_start_timestamp_id` ON `${TABLE_NAME}` (`start_timestamp_id`)" + }, + { + "name": "index_org_ranges_end_timestamp_id", + "unique": false, + "columnNames": [ + "end_timestamp_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_org_ranges_end_timestamp_id` ON `${TABLE_NAME}` (`end_timestamp_id`)" + } + ], + "foreignKeys": [ + { + "table": "org_timestamps", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "start_timestamp_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "org_timestamps", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "end_timestamp_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "org_timestamps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `string` TEXT NOT NULL, `is_active` INTEGER NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `hour` INTEGER, `minute` INTEGER, `second` INTEGER, `end_hour` INTEGER, `end_minute` INTEGER, `end_second` INTEGER, `repeater_type` INTEGER, `repeater_value` INTEGER, `repeater_unit` INTEGER, `habit_deadline_value` INTEGER, `habit_deadline_unit` INTEGER, `delay_type` INTEGER, `delay_value` INTEGER, `delay_unit` INTEGER, `timestamp` INTEGER NOT NULL, `end_timestamp` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "string", + "columnName": "string", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "is_active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "month", + "columnName": "month", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "second", + "columnName": "second", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endHour", + "columnName": "end_hour", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endMinute", + "columnName": "end_minute", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endSecond", + "columnName": "end_second", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "repeaterType", + "columnName": "repeater_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "repeaterValue", + "columnName": "repeater_value", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "repeaterUnit", + "columnName": "repeater_unit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "habitDeadlineValue", + "columnName": "habit_deadline_value", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "habitDeadlineUnit", + "columnName": "habit_deadline_unit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "delayType", + "columnName": "delay_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "delayValue", + "columnName": "delay_value", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "delayUnit", + "columnName": "delay_unit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTimestamp", + "columnName": "end_timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_org_timestamps_string", + "unique": true, + "columnNames": [ + "string" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_org_timestamps_string` ON `${TABLE_NAME}` (`string`)" + }, + { + "name": "index_org_timestamps_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_org_timestamps_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + }, + { + "name": "index_org_timestamps_end_timestamp", + "unique": false, + "columnNames": [ + "end_timestamp" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_org_timestamps_end_timestamp` ON `${TABLE_NAME}` (`end_timestamp`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "repos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_repos_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_repos_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `repo_id` INTEGER NOT NULL, `rook_url_id` INTEGER NOT NULL, FOREIGN KEY(`repo_id`) REFERENCES `repos`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`rook_url_id`) REFERENCES `rook_urls`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repoId", + "columnName": "repo_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rookUrlId", + "columnName": "rook_url_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_rooks_repo_id_rook_url_id", + "unique": true, + "columnNames": [ + "repo_id", + "rook_url_id" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_rooks_repo_id_rook_url_id` ON `${TABLE_NAME}` (`repo_id`, `rook_url_id`)" + }, + { + "name": "index_rooks_rook_url_id", + "unique": false, + "columnNames": [ + "rook_url_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rooks_rook_url_id` ON `${TABLE_NAME}` (`rook_url_id`)" + } + ], + "foreignKeys": [ + { + "table": "repos", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repo_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "rook_urls", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "rook_url_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "rook_urls", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_rook_urls_url", + "unique": true, + "columnNames": [ + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_rook_urls_url` ON `${TABLE_NAME}` (`url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "searches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `query` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "versioned_rooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `rook_id` INTEGER NOT NULL, `rook_revision` TEXT NOT NULL, `rook_mtime` INTEGER NOT NULL, FOREIGN KEY(`rook_id`) REFERENCES `rooks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rookId", + "columnName": "rook_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rookRevision", + "columnName": "rook_revision", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rookMtime", + "columnName": "rook_mtime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_versioned_rooks_rook_id", + "unique": false, + "columnNames": [ + "rook_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_versioned_rooks_rook_id` ON `${TABLE_NAME}` (`rook_id`)" + } + ], + "foreignKeys": [ + { + "table": "rooks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "rook_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "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, '13375dea4eb48e5f8577f47385db42d6')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/BookName.java b/app/src/main/java/com/orgzly/android/BookName.java index 56460239b..343b90b65 100644 --- a/app/src/main/java/com/orgzly/android/BookName.java +++ b/app/src/main/java/com/orgzly/android/BookName.java @@ -18,26 +18,23 @@ public class BookName { private static final String TAG = BookName.class.getName(); - private static final Pattern PATTERN = Pattern.compile("(.*)\\.(org)(\\.txt)?$"); + private static final Pattern PATTERN = Pattern.compile("(.*)\\.(org)(\\.txt)?(\\.gpg)?$"); private static final Pattern SKIP_PATTERN = Pattern.compile("^\\.#.*"); private final String mFileName; private final String mName; private final BookFormat mFormat; + private final boolean mEncrypted; - private BookName(String fileName, String name, BookFormat format) { + private BookName(String fileName, String name, BookFormat format, boolean encrypted) { mFileName = fileName; mName = name; mFormat = format; + mEncrypted = encrypted; } public static String getFileName(Context context, com.orgzly.android.db.entity.BookView bookView) { - if (bookView.getSyncedTo() != null) { - return getFileName(context, bookView.getSyncedTo().getUri()); - - } else { - return fileName(bookView.getBook().getName(), BookFormat.ORG); - } + return fileName(bookView.getBook().getName(), BookFormat.ORG, bookView.hasEncryption()); } public static String getFileName(Context context, Uri uri) { @@ -63,6 +60,13 @@ public static String getFileName(Context context, Uri uri) { return fileName; } + public static boolean getEncrypted(Context context, Uri uri) { + String fileName = getFileName(context, uri); + return ((fileName.length() > 4) + && fileName.substring(fileName.length() - 4).equals(".gpg")); + // todo replace by matcher + } + public static BookName getInstance(Context context, Rook rook) { return fromFileName(getFileName(context, rook.getUri())); } @@ -71,13 +75,20 @@ public static boolean isSupportedFormatFileName(String fileName) { return PATTERN.matcher(fileName).matches() && !SKIP_PATTERN.matcher(fileName).matches(); } - public static String fileName(String name, BookFormat format) { + // todo $!or introduce filename without .gpg ending to which the caller can add their .gpg + public static String fileName(String name, BookFormat format, boolean encrypted) { + String fullName = name; if (format == BookFormat.ORG) { - return name + ".org"; - + fullName += ".org"; } else { throw new IllegalArgumentException("Unsupported format " + format); } + + if (encrypted) { + fullName += ".gpg"; + } + + return fullName; } public static BookName fromFileName(String fileName) { @@ -87,9 +98,12 @@ public static BookName fromFileName(String fileName) { if (m.find()) { String name = m.group(1); String extension = m.group(2); + String gpgExtension = m.group(4); // todo ?needed if (extension.equals("org")) { - return new BookName(fileName, name, BookFormat.ORG); + boolean encrypted = (gpgExtension != null && gpgExtension.equals(".gpg")); + + return new BookName(fileName, name, BookFormat.ORG, encrypted); } } } @@ -109,4 +123,7 @@ public String getFileName() { return mFileName; } + public boolean getEncrypted() { + return mEncrypted; + } } diff --git a/app/src/main/java/com/orgzly/android/LocalStorage.java b/app/src/main/java/com/orgzly/android/LocalStorage.java index efc736238..9e8b369d4 100644 --- a/app/src/main/java/com/orgzly/android/LocalStorage.java +++ b/app/src/main/java/com/orgzly/android/LocalStorage.java @@ -32,7 +32,7 @@ public LocalStorage(Context context) { * @throws IOException if external directory is not available */ public File getExportFile(String name, BookFormat format) throws IOException { - return new File(downloadsDirectory(), BookName.fileName(name, format)); + return new File(downloadsDirectory(), BookName.fileName(name, format, false)); // todo exported files are unencrypted } /** diff --git a/app/src/main/java/com/orgzly/android/data/DataRepository.kt b/app/src/main/java/com/orgzly/android/data/DataRepository.kt index 01f248673..0156ea2ec 100644 --- a/app/src/main/java/com/orgzly/android/data/DataRepository.kt +++ b/app/src/main/java/com/orgzly/android/data/DataRepository.kt @@ -52,7 +52,6 @@ import com.orgzly.org.parser.OrgParser import com.orgzly.org.parser.OrgParserWriter import com.orgzly.org.utils.StateChangeLogic import java.io.* -import java.lang.IllegalStateException import java.util.* import java.util.concurrent.Callable import javax.inject.Inject @@ -82,7 +81,12 @@ class DataRepository @Inject constructor( val fileName = BookName.getFileName(context, book) - val loadedBook = loadBookFromRepo(book.linkRepo.id, book.linkRepo.type, book.linkRepo.url, fileName) + val loadedBook = loadBookFromRepo( + book.linkRepo.id, + book.linkRepo.type, + book.linkRepo.url, + fileName, + book.encryption?.passphrase) setBookLastActionAndSyncStatus(loadedBook!!.book.id, BookAction.forNow( BookAction.Type.INFO, @@ -148,27 +152,42 @@ class DataRepository @Inject constructor( bookView: BookView, @Suppress("UNUSED_PARAMETER") format: BookFormat) { - val uploadedBook: VersionedRook + var uploadedBook: VersionedRook? = null val repo = getRepoInstance(repoEntity.id, repoEntity.type, repoEntity.url) - val tmpFile = getTempBookFile() + var tmpFile = getTempBookFile() + val tmpFileEncrypted = getTempBookFile() try { /* Write to temporary file. */ NotesOrgExporter(this).exportBook(bookView.book, tmpFile) + if (bookView.hasEncryption()) { + val inFile: InputStream = BufferedInputStream(FileInputStream(tmpFile)) + val outFile: OutputStream = BufferedOutputStream(FileOutputStream(tmpFileEncrypted)) + + try { + MiscUtils.pgpEncrypt(inFile, outFile, fileName, bookView.encryption!!.passphrase) + } finally { + inFile.close() + outFile.close() + } + tmpFile = tmpFileEncrypted + } + /* Upload to repo. */ uploadedBook = repo.storeBook(tmpFile, fileName) } finally { /* Delete temporary file. */ tmpFile.delete() + tmpFileEncrypted.delete() } - updateBookLinkAndSync(bookView.book.id, uploadedBook) - - updateBookIsModified(bookView.book.id, false) - + if (uploadedBook != null) { + updateBookLinkAndSync(bookView.book.id, uploadedBook) + updateBookIsModified(bookView.book.id, false) + } } @Throws(IOException::class) @@ -321,16 +340,38 @@ class DataRepository @Inject constructor( return BookView(book.copy(id = id), 0) } - fun deleteBook(book: BookView, deleteLinked: Boolean) { + fun deleteBook(book: BookView, deleteLinked: Boolean, deleteLocal: Boolean) { // todo refactor into two methods if (deleteLinked) { book.syncedTo?.let { vrook -> val repo = getRepoInstance(vrook.repoId, vrook.repoType, vrook.repoUri.toString()) repo.delete(vrook.uri) } + + // todo ?no need to also delete book link + db.bookSync().deleteByBookId(book.book.id) } - db.book().delete(book.book) + if (deleteLocal) { + db.book().delete(book.book) + } + } + + fun removeBookSync(book: BookView) { // todo make remove syncedTo with optional deleteLinked + db.bookSync().deleteByBookId(book.book.id) + } + + fun setEncryptionPassphrase(book: BookView, passphrase: String?) { + if (book.hasEncryption()) { + db.bookEncryption().deleteByBookId(book.book.id) + } + + if (passphrase != null) { + db.bookEncryption().upsert(book.book.id, passphrase) + } + + // also set modified flag (even if passphrase stayed the same, for simplicity) + updateBookIsModified(book.book.id, true) } fun renameBook(bookView: BookView, name: String) { @@ -494,6 +535,39 @@ class DataRepository @Inject constructor( db.bookLink().deleteByBookId(bookId) } + fun setEncryption(bookId: Long, passphrase: String?) { + if (passphrase == null) { + deleteBookEncryption(bookId) + } else { + setEncryptionPassphrase(bookId, passphrase) + } + } + + private fun setEncryptionPassphrase(bookId: Long, passphrase: String) { + db.bookEncryption().upsert(bookId, passphrase) + } + + fun getDefaultPassphrase() : String? { + // todo use this? error handling + val defaultPassphrase = AppPreferences.defaultPassphrase(context) + if (defaultPassphrase.isEmpty()) { + return null + } + return defaultPassphrase + } + + fun getDefaultPassphraseOrThrow() : String { + val defaultPassphrase = AppPreferences.defaultPassphrase(context) + if (defaultPassphrase.isEmpty()) { + throw IllegalStateException("Default passphrase not set") // TODO i18n + } + return defaultPassphrase + } + + private fun deleteBookEncryption(bookId: Long) { + db.bookEncryption().deleteByBookId(bookId) + } + fun cycleVisibility(bookId: Long): Int { if (unfoldedNotesExist(bookId)) { foldAllNotes(bookId) @@ -1550,30 +1624,49 @@ class DataRepository @Inject constructor( } @Throws(IOException::class) - fun loadBookFromRepo(rook: Rook): BookView? { + fun loadBookFromRepo(rook: Rook, encryption: String?): BookView? { val fileName = BookName.getFileName(context, rook.uri) - return loadBookFromRepo(rook.repoId, rook.repoType, rook.repoUri.toString(), fileName) + return loadBookFromRepo(rook.repoId, rook.repoType, rook.repoUri.toString(), fileName, encryption) } @Throws(IOException::class) - fun loadBookFromRepo(repoId: Long, repoType: RepoType, repoUrl: String, fileName: String): BookView? { - val book: BookView? + fun loadBookFromRepo(repoId: Long, repoType: RepoType, repoUrl: String, fileName: String, encryption: String?): BookView? { + var book: BookView? = null val repo = getRepoInstance(repoId, repoType, repoUrl) val tmpFile = getTempBookFile() + val tmpFileDecrypted = getTempBookFile() try { /* Download from repo. */ + val vrook = repo.retrieveBook(fileName, tmpFile) + val plaintextBookFile: File = + if (encryption != null) { + val inFile: InputStream = BufferedInputStream(FileInputStream(tmpFile)) + val outFile: OutputStream = BufferedOutputStream(FileOutputStream(tmpFileDecrypted)) + + try { + MiscUtils.pgpDecrypt(inFile, outFile, encryption!!) + } finally { + inFile.close() + outFile.close() + } + + tmpFileDecrypted + } else { + tmpFile + } + val bookName = BookName.fromFileName(fileName) /* Store from file to Shelf. */ - book = loadBookFromFile(bookName.name, bookName.format, tmpFile, vrook) - + book = loadBookFromFile(bookName.name, bookName.format, plaintextBookFile, vrook) } finally { tmpFile.delete() + tmpFileDecrypted.delete() } return book diff --git a/app/src/main/java/com/orgzly/android/db/OrgzlyDatabase.kt b/app/src/main/java/com/orgzly/android/db/OrgzlyDatabase.kt index f1b6db6d4..19aadce75 100644 --- a/app/src/main/java/com/orgzly/android/db/OrgzlyDatabase.kt +++ b/app/src/main/java/com/orgzly/android/db/OrgzlyDatabase.kt @@ -22,6 +22,7 @@ import java.util.* @Database( entities = [ Book::class, + BookEncryption::class, BookLink::class, BookSync::class, DbRepoBook::class, @@ -38,12 +39,13 @@ import java.util.* VersionedRook::class ], - version = 155 + version = 156 ) @TypeConverters(com.orgzly.android.db.TypeConverters::class) abstract class OrgzlyDatabase : RoomDatabase() { abstract fun book(): BookDao + abstract fun bookEncryption(): BookEncryptionDao abstract fun bookLink(): BookLinkDao abstract fun bookView(): BookViewDao abstract fun bookSync(): BookSyncDao @@ -111,6 +113,7 @@ abstract class OrgzlyDatabase : RoomDatabase() { MIGRATION_152_153, MIGRATION_153_154, MIGRATION_154_155 + // MIGRATION_155_156// todo how to migrate to an encryption ready db , ) .addCallback(object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { diff --git a/app/src/main/java/com/orgzly/android/db/dao/BookEncryptionDao.kt b/app/src/main/java/com/orgzly/android/db/dao/BookEncryptionDao.kt new file mode 100644 index 000000000..1ae16bf3e --- /dev/null +++ b/app/src/main/java/com/orgzly/android/db/dao/BookEncryptionDao.kt @@ -0,0 +1,28 @@ +package com.orgzly.android.db.dao + +import androidx.room.* +import com.orgzly.android.db.entity.BookEncryption +import com.orgzly.android.db.entity.BookLink + +@Dao +abstract class BookEncryptionDao : BaseDao { + @Query("SELECT * FROM book_encryptions WHERE book_id = :bookId") + abstract fun getByBookId(bookId: Long): BookEncryption? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun replace(bookEncryption: BookEncryption): Long + + @Query("DELETE FROM book_encryptions WHERE book_id = :bookId") + abstract fun deleteByBookId(bookId: Long) + + @Transaction + open fun upsert(bookId: Long, passphrase: String) { + val enc = getByBookId(bookId) + + if (enc == null) { + insert(BookEncryption(bookId, passphrase)) + } else { + update(enc.copy(passphrase = passphrase)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/db/dao/BookSyncDao.kt b/app/src/main/java/com/orgzly/android/db/dao/BookSyncDao.kt index b4cc91def..61abcf60e 100644 --- a/app/src/main/java/com/orgzly/android/db/dao/BookSyncDao.kt +++ b/app/src/main/java/com/orgzly/android/db/dao/BookSyncDao.kt @@ -8,6 +8,9 @@ interface BookSyncDao : BaseDao { @Query("SELECT * FROM book_syncs WHERE book_id = :bookId") fun get(bookId: Long): BookSync? + @Query("DELETE FROM book_syncs WHERE book_id = :bookId") + abstract fun deleteByBookId(bookId: Long) + @Transaction fun upsert(bookId: Long, versionedRookId: Long) { val sync = get(bookId) diff --git a/app/src/main/java/com/orgzly/android/db/dao/BookViewDao.kt b/app/src/main/java/com/orgzly/android/db/dao/BookViewDao.kt index 557a6d7af..f6f4097f1 100644 --- a/app/src/main/java/com/orgzly/android/db/dao/BookViewDao.kt +++ b/app/src/main/java/com/orgzly/android/db/dao/BookViewDao.kt @@ -44,7 +44,10 @@ abstract class BookViewDao { synced_repos.url as synced_to_repoUri, synced_rook_urls.url as synced_to_uri, synced_versioned_rooks.rook_revision as synced_to_revision, - synced_versioned_rooks.rook_mtime as synced_to_mtime + synced_versioned_rooks.rook_mtime as synced_to_mtime, + + book_encryptions.passphrase as encryption_passphrase, + book_encryptions.book_id as encryption_book_id FROM books @@ -58,8 +61,10 @@ abstract class BookViewDao { LEFT JOIN rooks AS synced_rooks ON (synced_versioned_rooks.rook_id = synced_rooks.id) LEFT JOIN repos AS synced_repos ON (synced_rooks.repo_id = synced_repos.id) LEFT JOIN rook_urls AS synced_rook_urls ON (synced_rooks.rook_url_id = synced_rook_urls.id) + + LEFT JOIN book_encryptions ON (books.id = book_encryptions.book_id) """ - +//encryptions.passphrase as encryption_passphrase private const val ORDER_BY_TIME = "is_dummy, MAX(COALESCE(mtime, 0), COALESCE(synced_to_mtime, 0)) DESC, name" private const val ORDER_BY_NAME = "is_dummy, LOWER(COALESCE(books.title, name))" diff --git a/app/src/main/java/com/orgzly/android/db/entity/BookEncryption.kt b/app/src/main/java/com/orgzly/android/db/entity/BookEncryption.kt new file mode 100644 index 000000000..cee729169 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/db/entity/BookEncryption.kt @@ -0,0 +1,27 @@ +package com.orgzly.android.db.entity + +import androidx.room.* + +@Entity( + tableName = "book_encryptions", + + foreignKeys = [ + ForeignKey( + entity = Book::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("book_id"), + onDelete = ForeignKey.CASCADE) + ], + + indices = [ + // todo needed + ] +) +data class BookEncryption( + @PrimaryKey + @ColumnInfo(name = "book_id") + val bookId: Long, + + @ColumnInfo(name = "passphrase") + val passphrase: String +) diff --git a/app/src/main/java/com/orgzly/android/db/entity/BookView.kt b/app/src/main/java/com/orgzly/android/db/entity/BookView.kt index 1f1f5ff85..6589fa40d 100644 --- a/app/src/main/java/com/orgzly/android/db/entity/BookView.kt +++ b/app/src/main/java/com/orgzly/android/db/entity/BookView.kt @@ -13,7 +13,10 @@ data class BookView( val linkRepo: Repo? = null, @Embedded(prefix = "synced_to_") - val syncedTo: VersionedRook? = null + val syncedTo: VersionedRook? = null, + + @Embedded(prefix = "encryption_") + val encryption: BookEncryption? = null ) { fun hasLink(): Boolean { return linkRepo != null @@ -27,6 +30,10 @@ data class BookView( return syncedTo != null && book.isModified } + fun hasEncryption(): Boolean { + return encryption != null + } + fun isModified(): Boolean { return book.isModified } diff --git a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java index 9236fef97..1c3c3f4fb 100644 --- a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java +++ b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java @@ -969,6 +969,16 @@ public static boolean syncOnResume(Context context) { context.getResources().getBoolean(R.bool.pref_default_auto_sync_on_resume)); } + /* + * Encryption + */ + + public static String defaultPassphrase(Context context) { + return getDefaultSharedPreferences(context).getString( + context.getResources().getString(R.string.pref_key_encryption_default_passphrase), + context.getResources().getString(R.string.pref_key_encryption_default_passphrase_default_value)); + } + /* * Notes clipboard */ diff --git a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java index 389e0869e..d22526147 100644 --- a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java @@ -167,7 +167,7 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { public VersionedRook renameBook(Uri from, String name) throws IOException { DocumentFile fromDocFile = DocumentFile.fromSingleUri(context, from); BookName bookName = BookName.fromFileName(fromDocFile.getName()); - String newFileName = BookName.fileName(name, bookName.getFormat()); + String newFileName = BookName.fileName(name, bookName.getFormat(), bookName.getEncrypted()); /* Check if document already exists. */ DocumentFile existingFile = repoDocumentFile.findFile(newFileName); diff --git a/app/src/main/java/com/orgzly/android/sync/BookNamesake.java b/app/src/main/java/com/orgzly/android/sync/BookNamesake.java index 14e320207..2ad624f9c 100644 --- a/app/src/main/java/com/orgzly/android/sync/BookNamesake.java +++ b/app/src/main/java/com/orgzly/android/sync/BookNamesake.java @@ -2,6 +2,7 @@ import android.content.Context; +import com.orgzly.android.BookFormat; import com.orgzly.android.BookName; import com.orgzly.android.db.entity.BookView; import com.orgzly.android.db.entity.Repo; @@ -24,6 +25,11 @@ public class BookNamesake { /** Remote versioned books. */ private List versionedRooks = new ArrayList<>(); + /** Remote versioned books. */ + private List versionedRooksEncrypted = new ArrayList<>(); + /** Remote versioned books. */ + private List versionedRooksUnencrypted = new ArrayList<>(); + /** Current remote book that the local one is linking to. */ private VersionedRook latestLinkedRook; @@ -58,8 +64,10 @@ public static Map getAll(Context context, List b namesakes.put(name, pair); } + boolean encrypted = BookName.fromFileName(BookName.getFileName(context, book.getUri())).getEncrypted(); + /* Add remote book. */ - pair.addRook(book); + pair.addRook(book, encrypted); } return namesakes; @@ -82,8 +90,13 @@ public List getRooks() { return versionedRooks; } - public void addRook(VersionedRook vrook) { + public void addRook(VersionedRook vrook, boolean encrypted) { this.versionedRooks.add(vrook); + if (encrypted) { + this.versionedRooksEncrypted.add(vrook); + } else { + this.versionedRooksUnencrypted.add(vrook); + } } public BookSyncStatus getStatus() { @@ -117,7 +130,7 @@ public String toString() { * - Remote book exists */ /* TODO: Case: Remote book deleted? */ - public void updateStatus(int reposCount) { + public void updateStatus(Context context, int reposCount) { /* Sanity check. Group's name must come from somewhere - local or remote books. */ if (book == null && versionedRooks.isEmpty()) { throw new IllegalStateException("BookNameGroup does not contain any books"); @@ -127,7 +140,11 @@ public void updateStatus(int reposCount) { /* Remote books only */ if (versionedRooks.size() == 1) { - status = BookSyncStatus.NO_BOOK_ONE_ROOK; + if (versionedRooksUnencrypted.size() == 1) { + status = BookSyncStatus.NO_BOOK_ONE_ROOK; + } else { + status = BookSyncStatus.NO_BOOK_ONE_ROOK_ENCRYPTED; + } } else { status = BookSyncStatus.NO_BOOK_MULTIPLE_ROOKS; } @@ -160,9 +177,20 @@ public void updateStatus(int reposCount) { if (book.hasLink()) { // Book has link set. - VersionedRook latestLinkedRook = getLatestLinkedRookVersion(book, versionedRooks); + VersionedRook latestLinkedRookEncrypted = getLatestLinkedRookVersion(book, versionedRooksEncrypted); + VersionedRook latestLinkedRookUnencrypted = getLatestLinkedRookVersion(book, versionedRooksUnencrypted); + + if (latestLinkedRookEncrypted != null && latestLinkedRookUnencrypted != null) { + status = BookSyncStatus.CONFLICT_BOTH_ENCRYPTED_AND_UNENCRYPTED_ROOK_EXIST; + return; + } - if (latestLinkedRook == null) { + /* Fallback */ +// if (latestLinkedRook == null) { // todo don't go through all again +// getLatestLinkedRookVersion(book, versionedRooks); +// } + + if (latestLinkedRookEncrypted == null && latestLinkedRookUnencrypted == null) { /* Both local and remote book exist with the same name. * Book has a link, however that link is not pointing to an existing remote book. */ @@ -170,11 +198,28 @@ public void updateStatus(int reposCount) { // TODO: So what's the problem? Just save it then? But can we just overwrite whatever is link pointing too? return; } +// else if (book.hasEncryption() && latestLinkedRookEncrypted == null) { +// status = BookSyncStatus.BOOK_ENCRYPTED_WITH_LINK_AND_ONLY_UNENCRYPTED_ROOK_EXISTS; +// return; +// } else if (!book.hasEncryption() && latestLinkedRookUnencrypted == null) { +// status = BookSyncStatus.BOOK_UNENCRYPTED_WITH_LINK_AND_ONLY_ENCRYPTED_ROOK_EXISTS; +// return; +// } + VersionedRook latestLinkedRook = getLatestLinkedRookVersion(book, versionedRooks); +// if (book.hasEncryption()) { +// latestLinkedRook = latestLinkedRookEncrypted; +// } else { +// latestLinkedRook = latestLinkedRookUnencrypted; +// } setLatestLinkedRook(latestLinkedRook); if (book.getBook().isDummy()) { - status = BookSyncStatus.DUMMY_WITH_LINK; + if (!versionedRooksUnencrypted.isEmpty()) { + status = BookSyncStatus.DUMMY_WITH_LINK; + } else { // todo check + status = BookSyncStatus.DUMMY_WITH_LINK_ENCRYPTED; + } return; } @@ -183,11 +228,33 @@ public void updateStatus(int reposCount) { return; } + boolean bookEncryption = book.hasEncryption(); + boolean syncedEncryption = BookName.fromFileName(BookName.getFileName(context, book.getSyncedTo().getUri())).getEncrypted(); + if (bookEncryption != syncedEncryption) { + /* First sync after encryption toggle on previously synced book. */ + + String toBeSyncedFilename = BookName.fileName(name, BookFormat.ORG, bookEncryption); + + // todo if ((bookEncryption && latestLinkedRookEncrypted != null) || unenc && unenc) already_exists + + for (VersionedRook vrook : versionedRooks) { + if (BookName.getFileName(context, vrook.getUri()).equals(toBeSyncedFilename)){ + status = BookSyncStatus.CONFLICT_ENCRYPTION_TOGGLED_AND_TARGET_ROOK_EXISTS; + return; + } + } + + status = BookSyncStatus.BOOK_WITH_LINK_ENCRYPTION_TOGGLED_INITIAL_PUSH; + return; + } + if (! book.getSyncedTo().getUri().equals(latestLinkedRook.getUri())) { status = BookSyncStatus.CONFLICT_LAST_SYNCED_ROOK_AND_LATEST_ROOK_ARE_DIFFERENT; return; } + + /* Same revision, there was no remote change. */ if (book.getSyncedTo().getRevision().equals(latestLinkedRook.getRevision())) { /* Revision did not change. */ @@ -202,7 +269,11 @@ public void updateStatus(int reposCount) { if (book.isOutOfSync()) { // Local change status = BookSyncStatus.CONFLICT_BOTH_BOOK_AND_ROOK_MODIFIED; } else { - status = BookSyncStatus.BOOK_WITH_LINK_AND_ROOK_MODIFIED; + if (versionedRooksUnencrypted.size() == 1) { + status = BookSyncStatus.BOOK_WITH_LINK_AND_ROOK_MODIFIED; + } else { + status = BookSyncStatus.BOOK_WITH_LINK_AND_ROOK_MODIFIED_ENCRYPTED; + } } } @@ -213,7 +284,12 @@ public void updateStatus(int reposCount) { } else { if (versionedRooks.size() == 1) { - status = BookSyncStatus.DUMMY_WITHOUT_LINK_AND_ONE_ROOK; + // todo ?maybe two statuses encrypted/unencrypted + if (versionedRooksUnencrypted.size() == 1) { + status = BookSyncStatus.DUMMY_WITHOUT_LINK_AND_ONE_ROOK; + } else { + status = BookSyncStatus.DUMMY_WITHOUT_LINK_AND_ONE_ROOK_ENCRYPTED; + } } else { status = BookSyncStatus.DUMMY_WITHOUT_LINK_AND_MULTIPLE_ROOKS; } @@ -225,14 +301,20 @@ public void updateStatus(int reposCount) { private VersionedRook getLatestLinkedRookVersion(BookView bookView, List vrooks) { Repo linkRepo = bookView.getLinkRepo(); + VersionedRook latestRook = null; + if (linkRepo != null) { + long maxMTime = 0; for (VersionedRook vrook : vrooks) { if (linkRepo.getUrl().equals(vrook.getRepoUri().toString())){ - return vrook; + if (vrook.getMtime() > maxMTime) { + maxMTime = vrook.getMtime(); + latestRook = vrook; + } } } } - return null; + return latestRook; } } diff --git a/app/src/main/java/com/orgzly/android/sync/BookSyncStatus.kt b/app/src/main/java/com/orgzly/android/sync/BookSyncStatus.kt index a80af4efa..604b6b24d 100644 --- a/app/src/main/java/com/orgzly/android/sync/BookSyncStatus.kt +++ b/app/src/main/java/com/orgzly/android/sync/BookSyncStatus.kt @@ -9,6 +9,8 @@ enum class BookSyncStatus { NO_BOOK_MULTIPLE_ROOKS, // TODO: This should never be the case, as we already add dummy (dummy = there was no book) ONLY_BOOK_WITHOUT_LINK_AND_MULTIPLE_REPOS, BOOK_WITH_LINK_AND_ROOK_EXISTS_BUT_LINK_POINTING_TO_DIFFERENT_ROOK, + BOOK_ENCRYPTED_WITH_LINK_AND_ONLY_UNENCRYPTED_ROOK_EXISTS, + BOOK_UNENCRYPTED_WITH_LINK_AND_ONLY_ENCRYPTED_ROOK_EXISTS, ONLY_DUMMY, ROOK_AND_VROOK_HAVE_DIFFERENT_REPOS, @@ -16,17 +18,24 @@ enum class BookSyncStatus { CONFLICT_BOTH_BOOK_AND_ROOK_MODIFIED, CONFLICT_BOOK_WITH_LINK_AND_ROOK_BUT_NEVER_SYNCED_BEFORE, CONFLICT_LAST_SYNCED_ROOK_AND_LATEST_ROOK_ARE_DIFFERENT, + CONFLICT_ENCRYPTION_TOGGLED_AND_TARGET_ROOK_EXISTS, + CONFLICT_BOTH_ENCRYPTED_AND_UNENCRYPTED_ROOK_EXIST, /* Book can be loaded. */ NO_BOOK_ONE_ROOK, // TODO: Can this happen? We always load dummy. + NO_BOOK_ONE_ROOK_ENCRYPTED, // TODO: Can this happen? We always load dummy. DUMMY_WITHOUT_LINK_AND_ONE_ROOK, + DUMMY_WITHOUT_LINK_AND_ONE_ROOK_ENCRYPTED, BOOK_WITH_LINK_AND_ROOK_MODIFIED, + BOOK_WITH_LINK_AND_ROOK_MODIFIED_ENCRYPTED, DUMMY_WITH_LINK, + DUMMY_WITH_LINK_ENCRYPTED, /* Book can be saved. */ ONLY_BOOK_WITHOUT_LINK_AND_ONE_REPO, BOOK_WITH_LINK_LOCAL_MODIFIED, - ONLY_BOOK_WITH_LINK; + ONLY_BOOK_WITH_LINK, + BOOK_WITH_LINK_ENCRYPTION_TOGGLED_INITIAL_PUSH; // TODO: Extract string resources @JvmOverloads @@ -48,7 +57,13 @@ enum class BookSyncStatus { return "Notebook has no link and multiple repositories exist" BOOK_WITH_LINK_AND_ROOK_EXISTS_BUT_LINK_POINTING_TO_DIFFERENT_ROOK -> - return "Notebook has link and remote notebook with the same name exists, but link is pointing to a different remote notebook which does not exist" + return "Notebook has link and remote notebook with the same name exists, but link is pointing to a different remote nuotebook which does not exist" + + BOOK_ENCRYPTED_WITH_LINK_AND_ONLY_UNENCRYPTED_ROOK_EXISTS -> + return "Notebook has link and remote notebook with the same name exists, but local book is marked as encrypted and remote book is unencrypted" + + BOOK_UNENCRYPTED_WITH_LINK_AND_ONLY_ENCRYPTED_ROOK_EXISTS -> + return "Notebook has link and remote notebook with the same name exists, but local book is marked as unencrypted and remote book is encrypted" ONLY_DUMMY -> return "Only local dummy exists" @@ -65,10 +80,16 @@ enum class BookSyncStatus { CONFLICT_LAST_SYNCED_ROOK_AND_LATEST_ROOK_ARE_DIFFERENT -> return "Last synced notebook and latest remote notebook differ" - NO_BOOK_ONE_ROOK, DUMMY_WITHOUT_LINK_AND_ONE_ROOK, BOOK_WITH_LINK_AND_ROOK_MODIFIED, DUMMY_WITH_LINK -> + CONFLICT_ENCRYPTION_TOGGLED_AND_TARGET_ROOK_EXISTS -> + return "Notebook encryption was toggled but a remote book already exists in the place of the target remote book that would be newly created" + + CONFLICT_BOTH_ENCRYPTED_AND_UNENCRYPTED_ROOK_EXIST -> + return "Both encrypted and unencrypted versions of the remote book exist" + + NO_BOOK_ONE_ROOK, NO_BOOK_ONE_ROOK_ENCRYPTED, DUMMY_WITHOUT_LINK_AND_ONE_ROOK, DUMMY_WITHOUT_LINK_AND_ONE_ROOK_ENCRYPTED, BOOK_WITH_LINK_AND_ROOK_MODIFIED, BOOK_WITH_LINK_AND_ROOK_MODIFIED_ENCRYPTED, DUMMY_WITH_LINK, DUMMY_WITH_LINK_ENCRYPTED -> return "Loaded from $arg" - ONLY_BOOK_WITHOUT_LINK_AND_ONE_REPO, BOOK_WITH_LINK_LOCAL_MODIFIED, ONLY_BOOK_WITH_LINK -> + ONLY_BOOK_WITHOUT_LINK_AND_ONE_REPO, BOOK_WITH_LINK_LOCAL_MODIFIED, ONLY_BOOK_WITH_LINK, BOOK_WITH_LINK_ENCRYPTION_TOGGLED_INITIAL_PUSH -> return "Saved to $arg" else -> diff --git a/app/src/main/java/com/orgzly/android/sync/SyncService.kt b/app/src/main/java/com/orgzly/android/sync/SyncService.kt index 3bca2ebb4..8c2ca4d1c 100644 --- a/app/src/main/java/com/orgzly/android/sync/SyncService.kt +++ b/app/src/main/java/com/orgzly/android/sync/SyncService.kt @@ -393,7 +393,7 @@ class SyncService : Service() { namesake.book = dataRepository.createDummyBook(namesake.name) } - namesake.updateStatus(repos.size) + namesake.updateStatus(App.getAppContext(), repos.size) } return namesakes @@ -473,9 +473,15 @@ class SyncService : Service() { BookSyncStatus.NO_BOOK_MULTIPLE_ROOKS, BookSyncStatus.ONLY_BOOK_WITHOUT_LINK_AND_MULTIPLE_REPOS, BookSyncStatus.BOOK_WITH_LINK_AND_ROOK_EXISTS_BUT_LINK_POINTING_TO_DIFFERENT_ROOK, + // todo ?do the following two belong here + BookSyncStatus.BOOK_ENCRYPTED_WITH_LINK_AND_ONLY_UNENCRYPTED_ROOK_EXISTS, + BookSyncStatus.BOOK_UNENCRYPTED_WITH_LINK_AND_ONLY_ENCRYPTED_ROOK_EXISTS, BookSyncStatus.CONFLICT_BOTH_BOOK_AND_ROOK_MODIFIED, BookSyncStatus.CONFLICT_BOOK_WITH_LINK_AND_ROOK_BUT_NEVER_SYNCED_BEFORE, BookSyncStatus.CONFLICT_LAST_SYNCED_ROOK_AND_LATEST_ROOK_ARE_DIFFERENT, + BookSyncStatus.CONFLICT_ENCRYPTION_TOGGLED_AND_TARGET_ROOK_EXISTS, + // todo ?does the following belong here + BookSyncStatus.CONFLICT_BOTH_ENCRYPTED_AND_UNENCRYPTED_ROOK_EXIST, BookSyncStatus.ROOK_AND_VROOK_HAVE_DIFFERENT_REPOS, BookSyncStatus.ONLY_DUMMY -> bookAction = BookAction.forNow(BookAction.Type.ERROR, namesake.status.msg()) @@ -483,14 +489,62 @@ class SyncService : Service() { /* Load remote book. */ BookSyncStatus.NO_BOOK_ONE_ROOK, BookSyncStatus.DUMMY_WITHOUT_LINK_AND_ONE_ROOK -> { - dataRepository.loadBookFromRepo(namesake.rooks[0]) + dataRepository.loadBookFromRepo(namesake.rooks[0], null) bookAction = BookAction.forNow( BookAction.Type.INFO, namesake.status.msg(namesake.rooks[0].uri)) } - BookSyncStatus.DUMMY_WITH_LINK, BookSyncStatus.BOOK_WITH_LINK_AND_ROOK_MODIFIED -> { - dataRepository.loadBookFromRepo(namesake.latestLinkedRook) + BookSyncStatus.NO_BOOK_ONE_ROOK_ENCRYPTED-> { + val defaultPassphrase = dataRepository.getDefaultPassphraseOrThrow() + // TODO if the passphrase has been set manually on the dummy, how do we retrieve it? + val loadedBookView = dataRepository.loadBookFromRepo(namesake.rooks[0], defaultPassphrase) + + // TODO: could also write !! unwrap here, is always set since it will only fail through exception + loadedBookView?.let { dataRepository.setEncryption(it.book.id, defaultPassphrase) } + bookAction = BookAction.forNow( + BookAction.Type.INFO, + namesake.status.msg(namesake.rooks[0].uri)) + } + + BookSyncStatus.DUMMY_WITHOUT_LINK_AND_ONE_ROOK_ENCRYPTED -> { + val chosenPassphrase = namesake.book.encryption?.passphrase ?: dataRepository.getDefaultPassphraseOrThrow() + val loadedBookView = dataRepository.loadBookFromRepo(namesake.rooks[0], chosenPassphrase) + + // could also write !! unwrap here, should always be set since it will only fail through exception + loadedBookView?.let { dataRepository.setEncryption(it.book.id, chosenPassphrase) } + bookAction = BookAction.forNow( + BookAction.Type.INFO, + namesake.status.msg(namesake.rooks[0].uri)) + } + + BookSyncStatus.DUMMY_WITH_LINK -> { + dataRepository.loadBookFromRepo(namesake.latestLinkedRook, null) + bookAction = BookAction.forNow( + BookAction.Type.INFO, + namesake.status.msg(namesake.latestLinkedRook.uri)) + } + + BookSyncStatus.DUMMY_WITH_LINK_ENCRYPTED -> { + val defaultPassphrase = dataRepository.getDefaultPassphraseOrThrow() + val loadedBookView = dataRepository.loadBookFromRepo(namesake.rooks[0], defaultPassphrase) + + // TODO: could also write !! unwrap here, is always set since it will only fail through exception + loadedBookView?.let { dataRepository.setEncryption(it.book.id, defaultPassphrase) } + bookAction = BookAction.forNow( + BookAction.Type.INFO, + namesake.status.msg(namesake.latestLinkedRook.uri)) + } + + BookSyncStatus.BOOK_WITH_LINK_AND_ROOK_MODIFIED -> { + dataRepository.loadBookFromRepo(namesake.latestLinkedRook, null) + bookAction = BookAction.forNow( + BookAction.Type.INFO, + namesake.status.msg(namesake.latestLinkedRook.uri)) + } + + BookSyncStatus.BOOK_WITH_LINK_AND_ROOK_MODIFIED_ENCRYPTED -> { + dataRepository.loadBookFromRepo(namesake.latestLinkedRook, namesake.book.encryption?.passphrase) bookAction = BookAction.forNow( BookAction.Type.INFO, namesake.status.msg(namesake.latestLinkedRook.uri)) @@ -501,7 +555,7 @@ class SyncService : Service() { BookSyncStatus.ONLY_BOOK_WITHOUT_LINK_AND_ONE_REPO -> { repoEntity = dataRepository.getRepos().iterator().next() repoUrl = repoEntity.url - fileName = BookName.fileName(namesake.book.book.name, BookFormat.ORG) + fileName = BookName.fileName(namesake.book.book.name, BookFormat.ORG, namesake.book.hasEncryption()) dataRepository.saveBookToRepo(repoEntity, fileName, namesake.book, BookFormat.ORG) bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg(repoUrl)) } @@ -509,7 +563,9 @@ class SyncService : Service() { BookSyncStatus.BOOK_WITH_LINK_LOCAL_MODIFIED -> { repoEntity = namesake.book.linkRepo repoUrl = repoEntity!!.url - fileName = BookName.getFileName(App.getAppContext(), namesake.book.syncedTo!!.uri) + + fileName = BookName.getFileName(App.getAppContext(), namesake.book) + dataRepository.saveBookToRepo(repoEntity, fileName, namesake.book, BookFormat.ORG) bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg(repoUrl)) } @@ -517,7 +573,15 @@ class SyncService : Service() { BookSyncStatus.ONLY_BOOK_WITH_LINK -> { repoEntity = namesake.book.linkRepo repoUrl = repoEntity!!.url - fileName = BookName.fileName(namesake.book.book.name, BookFormat.ORG) + fileName = BookName.fileName(namesake.book.book.name, BookFormat.ORG, namesake.book.hasEncryption()) + dataRepository.saveBookToRepo(repoEntity, fileName, namesake.book, BookFormat.ORG) + bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg(repoUrl)) + } + + BookSyncStatus.BOOK_WITH_LINK_ENCRYPTION_TOGGLED_INITIAL_PUSH -> { + repoEntity = namesake.book.linkRepo + repoUrl = repoEntity!!.url + fileName = BookName.fileName(namesake.book.book.name, BookFormat.ORG, namesake.book.hasEncryption()) dataRepository.saveBookToRepo(repoEntity, fileName, namesake.book, BookFormat.ORG) bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg(repoUrl)) } diff --git a/app/src/main/java/com/orgzly/android/ui/books/BooksFragment.kt b/app/src/main/java/com/orgzly/android/ui/books/BooksFragment.kt index c7ed36d34..79f0fda26 100644 --- a/app/src/main/java/com/orgzly/android/ui/books/BooksFragment.kt +++ b/app/src/main/java/com/orgzly/android/ui/books/BooksFragment.kt @@ -36,6 +36,7 @@ import com.orgzly.android.usecase.BookDelete import com.orgzly.android.util.LogUtils import com.orgzly.android.util.MiscUtils import com.orgzly.databinding.DialogBookDeleteBinding +import com.orgzly.databinding.DialogBookEncryptionBinding import com.orgzly.databinding.DialogBookRenameBinding import com.orgzly.databinding.FragmentBooksBinding import javax.inject.Inject @@ -181,6 +182,10 @@ class BooksFragment : Fragment(), Fab, DrawerItem, OnViewHolderClickListener { + viewModel.bookEncryptionRequest(bookId) + } + R.id.books_context_menu_force_save -> { listener?.onForceSaveRequest(bookId) } @@ -264,7 +269,7 @@ class BooksFragment : Fragment(), Fab, DrawerItem, OnViewHolderClickListener { val deleteLinked = dialogBinding.deleteLinkedCheckbox.isChecked - viewModel.deleteBook(book.book.id, deleteLinked) + viewModel.deleteBook(book.book.id, deleteLinked, true) } } } @@ -282,6 +287,94 @@ class BooksFragment : Fragment(), Fab, DrawerItem, OnViewHolderClickListener defaultPassword?.let {dialogBinding.bookEncryptionPassphrase.setText(it)} } + + // todo ? if default password already used, disable button as well + +// dialogBinding.deleteLinkedCheckbox.setOnCheckedChangeListener { _, isChecked -> +// activity?.apply { +// val color = styledAttributes(R.styleable.ColorScheme) { typedArray -> +// val index = if (isChecked) { +// R.styleable.ColorScheme_text_primary_color +// } else { +// R.styleable.ColorScheme_text_disabled_color +// } +// +// typedArray.getColor(index, 0) +// } +// dialogBinding.deleteLinkedUrl.setTextColor(color) +// } +// } + + val dialogClickListener = DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + val encryptionToggled = (book.hasEncryption() == dialogBinding.bookEncryptionPassphrase.text.isNullOrEmpty()) + + if (encryptionToggled) { + // todo refactor into method that always deletes syncedTo and optionally removes remote book + + if (dialogBinding.deletePreviousCheckbox.isChecked) { + viewModel.deleteBook(book.book.id, true, false) + } + + // remove syncedTo + // todo I believe this is necessary since the old + // todo doesn't seem necessary since we will make sure that the old syncedTo url is not used inappropriately +// if(book.syncedTo != null) { +// viewModel.removeBookSync(book.book.id) +// } + } + + if (dialogBinding.bookEncryptionPassphrase.text.isNullOrEmpty()) { + viewModel.bookEncryption(book.book.id, null) + } else { + viewModel.bookEncryption(book.book.id, dialogBinding.bookEncryptionPassphrase.text.toString()) + } + } + } + } + + val builder = AlertDialog.Builder(context) + .setTitle(getString(R.string.set_encryption_with_quoted_argument, book.book.name)) // todo + .setPositiveButton(R.string.delete, dialogClickListener) // todo + .setNegativeButton(R.string.cancel, dialogClickListener) + + //if (book.syncedTo != null) { + //dialogBinding.deleteLinkedUrl.text = book.syncedTo.uri.toString() + builder.setView(dialogBinding.root) // todo ?needed + //} + + dialog = builder.show() + } + private fun renameBookDialog(book: BookView) { val dialogBinding = DialogBookRenameBinding.inflate(LayoutInflater.from(context)) @@ -344,6 +437,8 @@ class BooksFragment : Fragment(), Fab, DrawerItem, OnViewHolderClickListener + if (bookView != null) { + // todo if dummy & modified then it is out of sync + // maybe out of sync should be changed to include "not dummy" +// if (!bookView.isOutOfSync()) { + bookEncryptionDialog(bookView) +// } else { +// CommonActivity.showSnackbar(context, R.string.message_book_deleted) // todo error +// } + } + }) + viewModel.bookDeletedEvent.observeSingle(viewLifecycleOwner, Observer { CommonActivity.showSnackbar(context, R.string.message_book_deleted) }) + viewModel.bookEncryptionSetEvent.observeSingle(viewLifecycleOwner, Observer { + CommonActivity.showSnackbar(context, R.string.message_book_deleted) // todo string // todo ?always display success message + }) + viewModel.errorEvent.observeSingle(viewLifecycleOwner, Observer { error -> if (error is BookDelete.NotFound) { CommonActivity.showSnackbar(context, resources.getString( diff --git a/app/src/main/java/com/orgzly/android/ui/books/BooksViewModel.kt b/app/src/main/java/com/orgzly/android/ui/books/BooksViewModel.kt index bed5609af..7c3e9b99b 100644 --- a/app/src/main/java/com/orgzly/android/ui/books/BooksViewModel.kt +++ b/app/src/main/java/com/orgzly/android/ui/books/BooksViewModel.kt @@ -10,10 +10,7 @@ import com.orgzly.android.db.entity.Book import com.orgzly.android.db.entity.BookView import com.orgzly.android.ui.CommonViewModel import com.orgzly.android.ui.SingleLiveEvent -import com.orgzly.android.usecase.BookDelete -import com.orgzly.android.usecase.BookRename -import com.orgzly.android.usecase.UseCaseResult -import com.orgzly.android.usecase.UseCaseRunner +import com.orgzly.android.usecase.* import com.orgzly.android.util.LogUtils @@ -24,10 +21,16 @@ class BooksViewModel(private val dataRepository: DataRepository) : CommonViewMod val bookDeletedEvent: SingleLiveEvent = SingleLiveEvent() + val bookRemoveSyncEvent: SingleLiveEvent = SingleLiveEvent() + val bookRenameRequestEvent: SingleLiveEvent = SingleLiveEvent() val bookExportRequestEvent: SingleLiveEvent> = SingleLiveEvent() + val bookEncryptionRequestEvent: SingleLiveEvent = SingleLiveEvent() + + val bookEncryptionSetEvent: SingleLiveEvent = SingleLiveEvent() + enum class ViewState { LOADING, LOADED, @@ -62,21 +65,47 @@ class BooksViewModel(private val dataRepository: DataRepository) : CommonViewMod } } - fun deleteBook(bookId: Long, deleteLinked: Boolean) { + fun deleteBook(bookId: Long, deleteLinked: Boolean, deleteLocal: Boolean) { App.EXECUTORS.diskIO().execute { catchAndPostError { - val result = UseCaseRunner.run(BookDelete(bookId, deleteLinked)) + val result = UseCaseRunner.run(BookDelete(bookId, deleteLinked, deleteLocal)) bookDeletedEvent.postValue(result) } } } + fun removeBookSync(bookId: Long) { + App.EXECUTORS.diskIO().execute { + catchAndPostError { + val result = UseCaseRunner.run(BookRemoveSync(bookId)) + bookRemoveSyncEvent.postValue(result) + } + } + } + + fun bookEncryptionRequest(bookId: Long) { + App.EXECUTORS.diskIO().execute { + bookEncryptionRequestEvent.postValue(dataRepository.getBookView(bookId)) + } + } + + fun bookEncryption(bookId: Long, passphrase: String?) { + App.EXECUTORS.diskIO().execute { + catchAndPostError { + val result = UseCaseRunner.run(BookSetEncryption(bookId, passphrase)) + bookEncryptionSetEvent.postValue(result) // todo ??? + } + } + } + fun renameBookRequest(bookId: Long) { App.EXECUTORS.diskIO().execute { bookRenameRequestEvent.postValue(dataRepository.getBookView(bookId)) } } + // todo add encryptionevent here + fun renameBook(book: BookView, name: String) { App.EXECUTORS.diskIO().execute { catchAndPostError { diff --git a/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt b/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt index e0fa20981..ba0d68f0a 100644 --- a/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt @@ -6,6 +6,7 @@ import android.content.SharedPreferences import android.os.Build import android.os.Bundle import android.os.Handler +import android.text.InputType import android.view.View import androidx.annotation.StringRes import androidx.preference.* @@ -27,6 +28,8 @@ import com.orgzly.android.util.LogUtils import com.orgzly.android.widgets.ListWidgetProvider import java.util.* +import androidx.preference.EditTextPreference; + /** * Displays settings. */ @@ -75,6 +78,12 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP } } + preference(R.string.pref_key_encryption_default_passphrase).let { + (it as? EditTextPreference)?.setOnBindEditTextListener { + it.inputType = (InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD) + } + } + setupVersionPreference() setDefaultStateForNewNote() @@ -397,6 +406,7 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP "prefs_screen_reminders" to R.xml.prefs_screen_reminders, "prefs_screen_sync" to R.xml.prefs_screen_sync, "prefs_screen_auto_sync" to R.xml.prefs_screen_auto_sync, // Sub-screen + "prefs_screen_encryption" to R.xml.prefs_screen_encryption, // Sub-screen "prefs_screen_org_file_format" to R.xml.prefs_screen_org_file_format, // Sub-screen "prefs_screen_org_mode_tags_indent" to R.xml.prefs_screen_org_mode_tags_indent, // Sub-screen "prefs_screen_widget" to R.xml.prefs_screen_widget, // Sub-screen diff --git a/app/src/main/java/com/orgzly/android/usecase/BookDelete.kt b/app/src/main/java/com/orgzly/android/usecase/BookDelete.kt index d1e62c33b..da1653cfe 100644 --- a/app/src/main/java/com/orgzly/android/usecase/BookDelete.kt +++ b/app/src/main/java/com/orgzly/android/usecase/BookDelete.kt @@ -2,11 +2,11 @@ package com.orgzly.android.usecase import com.orgzly.android.data.DataRepository -class BookDelete(val bookId: Long, val deleteLinked: Boolean) : UseCase() { +class BookDelete(val bookId: Long, val deleteLinked: Boolean, val deleteLocal: Boolean) : UseCase() { override fun run(dataRepository: DataRepository): UseCaseResult { val book = dataRepository.getBookView(bookId) ?: throw NotFound() - dataRepository.deleteBook(book, deleteLinked) + dataRepository.deleteBook(book, deleteLinked, deleteLocal) return UseCaseResult( modifiesLocalData = true, diff --git a/app/src/main/java/com/orgzly/android/usecase/BookRemoveSync.kt b/app/src/main/java/com/orgzly/android/usecase/BookRemoveSync.kt new file mode 100644 index 000000000..9bb8b3f3e --- /dev/null +++ b/app/src/main/java/com/orgzly/android/usecase/BookRemoveSync.kt @@ -0,0 +1,18 @@ +package com.orgzly.android.usecase + +import com.orgzly.android.data.DataRepository + +class BookRemoveSync(val bookId: Long) : UseCase() { + override fun run(dataRepository: DataRepository): UseCaseResult { + val book = dataRepository.getBookView(bookId) ?: throw NotFound() + + dataRepository.removeBookSync(book) + + return UseCaseResult( + modifiesLocalData = true, + triggersSync = SYNC_DATA_MODIFIED + ) + } + + class NotFound: Throwable() +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/usecase/BookSetEncryption.kt b/app/src/main/java/com/orgzly/android/usecase/BookSetEncryption.kt new file mode 100644 index 000000000..f298ceae9 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/usecase/BookSetEncryption.kt @@ -0,0 +1,18 @@ +package com.orgzly.android.usecase + +import com.orgzly.android.data.DataRepository + +class BookSetEncryption(val bookId: Long, val passphrase: String?) : UseCase() { + override fun run(dataRepository: DataRepository): UseCaseResult { + val book = dataRepository.getBookView(bookId) ?: throw NotFound() + + dataRepository.setEncryptionPassphrase(book, passphrase) + + return UseCaseResult( + modifiesLocalData = true, + triggersSync = SYNC_DATA_MODIFIED // todo ?more needed + ) + } + + class NotFound: Throwable() +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/util/MiscUtils.java b/app/src/main/java/com/orgzly/android/util/MiscUtils.java index d758d58b8..852e101f5 100644 --- a/app/src/main/java/com/orgzly/android/util/MiscUtils.java +++ b/app/src/main/java/com/orgzly/android/util/MiscUtils.java @@ -1,3 +1,4 @@ + package com.orgzly.android.util; @@ -7,9 +8,32 @@ import android.text.Html; import android.text.Spanned; import android.text.TextWatcher; +import android.util.Log; import android.widget.TextView; +import org.bouncycastle.bcpg.ArmoredInputStream; +import org.bouncycastle.bcpg.BCPGOutputStream; +import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags; +import org.bouncycastle.openpgp.PGPCompressedData; +import org.bouncycastle.openpgp.PGPCompressedDataGenerator; +import org.bouncycastle.openpgp.PGPDataValidationException; +import org.bouncycastle.openpgp.PGPEncryptedDataGenerator; +import org.bouncycastle.openpgp.PGPEncryptedDataList; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPLiteralData; +import org.bouncycastle.openpgp.PGPLiteralDataGenerator; +import org.bouncycastle.openpgp.PGPMarker; +import org.bouncycastle.openpgp.PGPPBEEncryptedData; +import org.bouncycastle.openpgp.PGPUtil; +import org.bouncycastle.openpgp.bc.BcPGPObjectFactory; +import org.bouncycastle.openpgp.operator.bc.BcPBEDataDecryptorFactory; +import org.bouncycastle.openpgp.operator.bc.BcPBEKeyEncryptionMethodGenerator; +import org.bouncycastle.openpgp.operator.bc.BcPGPDataEncryptorBuilder; +import org.bouncycastle.openpgp.operator.bc.BcPGPDigestCalculatorProvider; +import org.bouncycastle.util.io.Streams; + import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -22,7 +46,10 @@ import java.io.PrintWriter; import java.io.Reader; import java.security.MessageDigest; +import java.security.SecureRandom; import java.util.ArrayList; +import java.util.Date; +import java.util.Iterator; import java.util.Map; public class MiscUtils { @@ -185,6 +212,173 @@ public static void copyFile(File src, File dst) throws IOException { out.close(); } + /** + * Encrypt data with AES-256 symmetric encryption and wrap it in a PGP packet. + * + * This code is based on the encryption code in OpenKeychain. It uses the bouncycastle cryptography provider + * as an extra library since some features were removed in recent Android APIs. + * See https://android-developers.googleblog.com/2018/03/cryptography-changes-in-android-p.html for details + * + * @param src The input stream to be encrypted. + * @param dst The output stream to receive the PGP data + * @param originalFilename The filename to store as PGP metadata. + * @param passphrase The passphrase to derive the encryption key from. + * @throws IOException + * @throws PGPException + */ + public static void pgpEncrypt( + InputStream src, + OutputStream dst, + String originalFilename, + String passphrase) throws IOException, PGPException { + int symmetricEncryptionAlgorithm = SymmetricKeyAlgorithmTags.AES_256; + + // a PGP generator must be created and a passphrase to key generation method must be set + BcPGPDataEncryptorBuilder encryptorBuilder = new BcPGPDataEncryptorBuilder(symmetricEncryptionAlgorithm) + .setSecureRandom(new SecureRandom()) + .setWithIntegrityPacket(true); + PGPEncryptedDataGenerator encGen = + new PGPEncryptedDataGenerator(encryptorBuilder); + BcPBEKeyEncryptionMethodGenerator symmetricEncryptionGenerator = + new BcPBEKeyEncryptionMethodGenerator(passphrase.toCharArray()); + encGen.addMethod(symmetricEncryptionGenerator); + + OutputStream encryptionOut = encGen.open(dst, new byte[1 << 16]); + // since books contain text, we also use compression + PGPCompressedDataGenerator compressedGen = new PGPCompressedDataGenerator(PGPCompressedDataGenerator.ZIP); + BCPGOutputStream bcpgOut = new BCPGOutputStream(compressedGen.open(encryptionOut)); + PGPLiteralDataGenerator literalGen = new PGPLiteralDataGenerator(); + char literalDataFormatTag = PGPLiteralData.BINARY; + OutputStream pOut = literalGen.open(bcpgOut, literalDataFormatTag, + originalFilename, new Date(), new byte[1 << 16]); + Streams.pipeAll(src, pOut); + + literalGen.close(); + compressedGen.close(); + encryptionOut.close(); + // close to write PGP footer + //encGen.close(); + dst.close(); + } + + /** + * Skip PGP marker packets while iterating PGP objects + */ + public static Object nextObjectSkipMarker(BcPGPObjectFactory fact) throws IOException{ + Object o = fact.nextObject(); + while (o instanceof PGPMarker) { + o = fact.nextObject(); + } + return o; + } + + /** + * Decrypt a symmetrically encrypted PGP packet. + * + * This code is based on the encryption code in OpenKeychain. It uses the bouncycastle cryptography provider + * as an extra library since some features were removed in recent Android APIs. + * See https://android-developers.googleblog.com/2018/03/cryptography-changes-in-android-p.html for details + * + * @param src The symetrycally encrypted PGP packet data. + * @param dst Output plaintext stream. + * @param passphrase The passphrase to derive the decryption key from. + * @throws IOException + * @throws PGPException + */ + public static void pgpDecrypt(InputStream src, OutputStream dst, String passphrase) throws IOException, PGPException { + InputStream pgpIn = PGPUtil.getDecoderStream(src); + + if (pgpIn instanceof ArmoredInputStream) { + throw new PGPException("ASCII Armored PGP data is not supported."); + } + + BcPGPObjectFactory pgpF = new BcPGPObjectFactory(pgpIn); + + Object obj = nextObjectSkipMarker(pgpF); + if (!(obj instanceof PGPEncryptedDataList)) { + throw new PGPException("Unencrypted PGP data is not supported."); + } + PGPEncryptedDataList enc = (PGPEncryptedDataList) obj; + + // if there are more than one symmetric encrypted packet, get only the first + PGPPBEEncryptedData encryptedDataSymmetric = null; + Iterator it = enc.getEncryptedDataObjects(); + while (it.hasNext()) { + Object packetObj = it.next(); + if (!(packetObj instanceof PGPPBEEncryptedData)) { + continue; + } + encryptedDataSymmetric = (PGPPBEEncryptedData) packetObj; + break; + } + + if (encryptedDataSymmetric == null) { + throw new PGPException("Asymmetrically encrypted PGP data is not supported."); + } + + // decrypt + InputStream cleartextStream; + BcPGPDigestCalculatorProvider digestCalcProvider = new BcPGPDigestCalculatorProvider(); + BcPBEDataDecryptorFactory decryptorFactory = new BcPBEDataDecryptorFactory(passphrase.toCharArray(), digestCalcProvider); + try { + cleartextStream = encryptedDataSymmetric.getDataStream(decryptorFactory); + } catch (PGPDataValidationException ex) { + throw new PGPException("Failed to decrypt data. Wrong password?"); + } + + BcPGPObjectFactory plainFact = new BcPGPObjectFactory(cleartextStream); + Object dataChunk = nextObjectSkipMarker(plainFact); + + // if we're trying to read a file generated by someone other than us + // the data might not be compressed, so we check the return type from + // the factory and behave accordingly. + if (dataChunk instanceof PGPCompressedData) { + PGPCompressedData compressedData = (PGPCompressedData) dataChunk; + plainFact = new BcPGPObjectFactory(compressedData.getDataStream()); + dataChunk = nextObjectSkipMarker(plainFact); + } + + if (!(dataChunk instanceof PGPLiteralData)) { + throw new UnsupportedOperationException("Encountered an error reading input data!"); + } + PGPLiteralData literalData = (PGPLiteralData) dataChunk; + InputStream dataIn = literalData.getInputStream(); + + // write out + Streams.pipeAll(dataIn, dst); + + // require integrity check (must be done after piping out the data) + if (!encryptedDataSymmetric.isIntegrityProtected()) { + throw new PGPException("Missing the Modification Detection Code (MDC) packet!"); + } + if (!encryptedDataSymmetric.verify()) { + throw new PGPException("Integrity check error!"); + } + } + + /** + * Add a .gpg file extension to a file name if not already present. + */ + public static String ensureGpgExtensionFileName(String fileName) { + if ((fileName.length() > 4) + && fileName.substring(fileName.length() - 4).equals(".gpg")) { + return fileName; + } else { + return "$fileName.gpg"; + } + } + + /** + * Remove a .gpg file extension from a file name if present. + */ + public static String ensureNoGpgExtensionFileName(String fileName) { + if ((fileName.length() > 4) && fileName.substring(fileName.length() - 4).equals(".gpg")) { + return fileName.substring(0, fileName.length() - 4); + } else { + return fileName; + } + } + /** * Clear {@link TextInputLayout} error after its text has been modified. */ diff --git a/app/src/main/java/com/orgzly/android/util/UriUtils.java b/app/src/main/java/com/orgzly/android/util/UriUtils.java index 0c2eaf3db..b9d607326 100644 --- a/app/src/main/java/com/orgzly/android/util/UriUtils.java +++ b/app/src/main/java/com/orgzly/android/util/UriUtils.java @@ -50,7 +50,7 @@ public static Uri getUriForNewName(Uri uri, String name) { BookName bookName = BookName.fromFileName(uri.getLastPathSegment()); BookFormat format = bookName.getFormat(); - String newFilename = BookName.fileName(name, format); + String newFilename = BookName.fileName(name, format, bookName.getEncrypted()); return UriUtils.dirUri(uri) // Old Uri without file name .buildUpon() diff --git a/app/src/main/res/layout/activity_repo_dropbox.xml b/app/src/main/res/layout/activity_repo_dropbox.xml index 89d7baa7c..c6eed9fea 100644 --- a/app/src/main/res/layout/activity_repo_dropbox.xml +++ b/app/src/main/res/layout/activity_repo_dropbox.xml @@ -24,6 +24,7 @@ tools:context=".android.ui.main.MainActivity"> + + + @@ -72,4 +75,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_book_encryption.xml b/app/src/main/res/layout/dialog_book_encryption.xml new file mode 100644 index 000000000..389c18e3a --- /dev/null +++ b/app/src/main/res/layout/dialog_book_encryption.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + +