diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6c01f4ac..ea837ecd 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -16,24 +16,28 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 - name: Run tests run: ./gradlew ktfmtCheck build: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 - name: Retrieve keystore for apk signing env: ENCODED_KEYSTORE: ${{ secrets.KEYSTORE }} @@ -54,12 +58,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 - name: Retrieve keystore for apk signing env: ENCODED_KEYSTORE: ${{ secrets.KEYSTORE }} diff --git a/.gitignore b/.gitignore index 42ad7c01..3afc5a8d 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,6 @@ app/app/src/main/assets/search_plugins/1337.json app/app/src/main/assets/search_plugins/isohunt_nz.json app/app/src/main/assets/search_plugins/zooqle.json app/apikey.properties +/extra_assets/docker/jackett/data +/extra_assets/docker/prowlarr +*.salive diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 2059edd4..0a4fd886 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -3,6 +3,7 @@ import java.util.Properties plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + alias(libs.plugins.kotlin.serialization) id("org.jetbrains.kotlin.plugin.parcelize") id("com.google.dagger.hilt.android") id("androidx.navigation.safeargs.kotlin") @@ -35,6 +36,8 @@ ktfmt { kotlinLangStyle() } +kotlin { jvmToolchain(11) } + android { namespace = "com.github.livingwithhippos.unchained" compileSdk = 36 @@ -43,8 +46,8 @@ android { applicationId = "com.github.livingwithhippos.unchained" minSdk = 27 targetSdk = 36 - versionCode = 56 - versionName = "1.5.1" + versionCode = 57 + versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -57,8 +60,8 @@ android { excludes.addAll( listOf( "META-INF/*.version", - // manually added, markdown files should not be needed - // was crashing with the jakarta xml bind api + // manually added, Markdown files should not be needed + // was crashing with the jakarta XML bind api "META-INF/*.md", "META-INF/proguard/*", "/*.properties", @@ -134,7 +137,6 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { jvmTarget = "11" } buildFeatures { viewBinding = true buildConfig = true @@ -165,13 +167,6 @@ dependencies { implementation(libs.datastore.core) implementation(libs.datastore.prefs) - implementation(libs.jackson.kotlin) - implementation(libs.jackson.xml) - implementation(libs.woodstox) - // replaced legacy jaxb with jakarta - // https://github.com/FasterXML/jackson-modules-base - // implementation(libs.stax) - implementation(libs.jakarta.xmlapi) implementation(libs.documentfile) ksp(libs.moshi.codegen) @@ -195,6 +190,7 @@ dependencies { implementation(libs.coroutines.core) implementation(libs.coroutines.android) + implementation(libs.kotlin.serialization.json) implementation(libs.material.version3) diff --git a/app/app/proguard-rules.pro b/app/app/proguard-rules.pro index f61acb38..d4009dac 100644 --- a/app/app/proguard-rules.pro +++ b/app/app/proguard-rules.pro @@ -20,16 +20,12 @@ # hide the original source file name. #-renamesourcefileattribute SourceFile -# avoid rewriting of proto datastore variables name. Remove when https://android-review.googlesource.com/c/platform/frameworks/support/+/1433465/ is available --keep class * extends com.google.protobuf.GeneratedMessageLite { - ; - } - # https://github.com/square/retrofit#r8--proguard # With R8 full mode generic signatures are stripped for classes that are not # kept. Suspend functions are wrapped in continuations where the type argument # is used. -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation +-dontwarn com.google.re2j.** # With R8 full mode generic signatures are stripped for classes that are not kept. -keep,allowobfuscation,allowshrinking class retrofit2.Response diff --git a/app/app/schemas/com.github.livingwithhippos.unchained.data.local.UnchaineDB/10.json b/app/app/schemas/com.github.livingwithhippos.unchained.data.local.UnchaineDB/10.json new file mode 100644 index 00000000..0b92e7a4 --- /dev/null +++ b/app/app/schemas/com.github.livingwithhippos.unchained.data.local.UnchaineDB/10.json @@ -0,0 +1,478 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "da7f488b501caf4a61821b0479e92f1c", + "entities": [ + { + "tableName": "host_regex", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`regex` TEXT NOT NULL, `type` INTEGER NOT NULL, PRIMARY KEY(`regex`))", + "fields": [ + { + "fieldPath": "regex", + "columnName": "regex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "regex" + ] + } + }, + { + "tableName": "kodi_device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT, `password` TEXT, `is_default` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "ip", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "repository", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link` TEXT NOT NULL, PRIMARY KEY(`link`))", + "fields": [ + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "link" + ] + } + }, + { + "tableName": "repository_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link` TEXT NOT NULL, `version` REAL NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `author` TEXT NOT NULL, PRIMARY KEY(`link`), FOREIGN KEY(`link`) REFERENCES `repository`(`link`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "link" + ] + }, + "foreignKeys": [ + { + "table": "repository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "link" + ], + "referencedColumns": [ + "link" + ] + } + ] + }, + { + "tableName": "plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repository` TEXT NOT NULL, `plugin_name` TEXT NOT NULL, `search_enabled` INTEGER, PRIMARY KEY(`repository`, `plugin_name`), FOREIGN KEY(`repository`) REFERENCES `repository_info`(`link`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repository", + "columnName": "repository", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "plugin_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "searchEnabled", + "columnName": "search_enabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repository", + "plugin_name" + ] + }, + "foreignKeys": [ + { + "table": "repository_info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repository" + ], + "referencedColumns": [ + "link" + ] + } + ] + }, + { + "tableName": "plugin_version", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`plugin_repository` TEXT NOT NULL, `plugin` TEXT NOT NULL, `version` REAL NOT NULL, `engine` REAL NOT NULL, `plugin_link` TEXT NOT NULL, `disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`plugin_repository`, `plugin`, `version`), FOREIGN KEY(`plugin_repository`, `plugin`) REFERENCES `plugin`(`repository`, `plugin_name`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repository", + "columnName": "plugin_repository", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "plugin", + "columnName": "plugin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "engine", + "columnName": "engine", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "plugin_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disabled", + "columnName": "disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "plugin_repository", + "plugin", + "version" + ] + }, + "foreignKeys": [ + { + "table": "plugin", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "plugin_repository", + "plugin" + ], + "referencedColumns": [ + "repository", + "plugin_name" + ] + } + ] + }, + { + "tableName": "remote_device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `is_default` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "remote_service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `device_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT, `password` TEXT, `type` INTEGER NOT NULL, `is_default` INTEGER NOT NULL, `api_token` TEXT NOT NULL, `field_1` TEXT NOT NULL, `field_2` TEXT NOT NULL, `field_3` TEXT NOT NULL, FOREIGN KEY(`device_id`) REFERENCES `remote_device`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "device", + "columnName": "device_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiToken", + "columnName": "api_token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldOne", + "columnName": "field_1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldTwo", + "columnName": "field_2", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldThree", + "columnName": "field_3", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "remote_device", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "device_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "complete_remote_service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `username` TEXT, `password` TEXT, `type` INTEGER NOT NULL, `is_default` INTEGER NOT NULL, `api_token` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `field_1` TEXT NOT NULL, `field_2` TEXT NOT NULL, `field_3` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiToken", + "columnName": "api_token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fieldOne", + "columnName": "field_1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldTwo", + "columnName": "field_2", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldThree", + "columnName": "field_3", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, 'da7f488b501caf4a61821b0479e92f1c')" + ] + } +} \ No newline at end of file diff --git a/app/app/schemas/com.github.livingwithhippos.unchained.data.local.UnchaineDB/8.json b/app/app/schemas/com.github.livingwithhippos.unchained.data.local.UnchaineDB/8.json new file mode 100644 index 00000000..fa7ddb3e --- /dev/null +++ b/app/app/schemas/com.github.livingwithhippos.unchained.data.local.UnchaineDB/8.json @@ -0,0 +1,465 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "2ae4608056a2c08210ff444ef4655e6f", + "entities": [ + { + "tableName": "host_regex", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`regex` TEXT NOT NULL, `type` INTEGER NOT NULL, PRIMARY KEY(`regex`))", + "fields": [ + { + "fieldPath": "regex", + "columnName": "regex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "regex" + ] + } + }, + { + "tableName": "kodi_device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT, `password` TEXT, `is_default` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "ip", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "repository", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link` TEXT NOT NULL, PRIMARY KEY(`link`))", + "fields": [ + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "link" + ] + } + }, + { + "tableName": "repository_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link` TEXT NOT NULL, `version` REAL NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `author` TEXT NOT NULL, PRIMARY KEY(`link`), FOREIGN KEY(`link`) REFERENCES `repository`(`link`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "link" + ] + }, + "foreignKeys": [ + { + "table": "repository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "link" + ], + "referencedColumns": [ + "link" + ] + } + ] + }, + { + "tableName": "plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repository` TEXT NOT NULL, `plugin_name` TEXT NOT NULL, `search_enabled` INTEGER, PRIMARY KEY(`repository`, `plugin_name`), FOREIGN KEY(`repository`) REFERENCES `repository_info`(`link`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repository", + "columnName": "repository", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "plugin_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "searchEnabled", + "columnName": "search_enabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repository", + "plugin_name" + ] + }, + "foreignKeys": [ + { + "table": "repository_info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repository" + ], + "referencedColumns": [ + "link" + ] + } + ] + }, + { + "tableName": "plugin_version", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`plugin_repository` TEXT NOT NULL, `plugin` TEXT NOT NULL, `version` REAL NOT NULL, `engine` REAL NOT NULL, `plugin_link` TEXT NOT NULL, PRIMARY KEY(`plugin_repository`, `plugin`, `version`), FOREIGN KEY(`plugin_repository`, `plugin`) REFERENCES `plugin`(`repository`, `plugin_name`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repository", + "columnName": "plugin_repository", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "plugin", + "columnName": "plugin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "engine", + "columnName": "engine", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "plugin_link", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "plugin_repository", + "plugin", + "version" + ] + }, + "foreignKeys": [ + { + "table": "plugin", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "plugin_repository", + "plugin" + ], + "referencedColumns": [ + "repository", + "plugin_name" + ] + } + ] + }, + { + "tableName": "remote_device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `is_default` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "remote_service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `device_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT, `password` TEXT, `type` INTEGER NOT NULL, `is_default` INTEGER NOT NULL, `api_token` TEXT NOT NULL, `field_1` TEXT NOT NULL, `field_2` TEXT NOT NULL, `field_3` TEXT NOT NULL, FOREIGN KEY(`device_id`) REFERENCES `remote_device`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "device", + "columnName": "device_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiToken", + "columnName": "api_token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldOne", + "columnName": "field_1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldTwo", + "columnName": "field_2", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldThree", + "columnName": "field_3", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "remote_device", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "device_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "complete_remote_service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `username` TEXT, `password` TEXT, `type` INTEGER NOT NULL, `is_default` INTEGER NOT NULL, `api_token` TEXT NOT NULL, `field_1` TEXT NOT NULL, `field_2` TEXT NOT NULL, `field_3` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiToken", + "columnName": "api_token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldOne", + "columnName": "field_1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldTwo", + "columnName": "field_2", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldThree", + "columnName": "field_3", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, '2ae4608056a2c08210ff444ef4655e6f')" + ] + } +} \ No newline at end of file diff --git a/app/app/schemas/com.github.livingwithhippos.unchained.data.local.UnchaineDB/9.json b/app/app/schemas/com.github.livingwithhippos.unchained.data.local.UnchaineDB/9.json new file mode 100644 index 00000000..3955d47a --- /dev/null +++ b/app/app/schemas/com.github.livingwithhippos.unchained.data.local.UnchaineDB/9.json @@ -0,0 +1,472 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "0057d7c7f20f5a00ceeed824e378aee6", + "entities": [ + { + "tableName": "host_regex", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`regex` TEXT NOT NULL, `type` INTEGER NOT NULL, PRIMARY KEY(`regex`))", + "fields": [ + { + "fieldPath": "regex", + "columnName": "regex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "regex" + ] + } + }, + { + "tableName": "kodi_device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT, `password` TEXT, `is_default` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "ip", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "repository", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link` TEXT NOT NULL, PRIMARY KEY(`link`))", + "fields": [ + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "link" + ] + } + }, + { + "tableName": "repository_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link` TEXT NOT NULL, `version` REAL NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `author` TEXT NOT NULL, PRIMARY KEY(`link`), FOREIGN KEY(`link`) REFERENCES `repository`(`link`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "link" + ] + }, + "foreignKeys": [ + { + "table": "repository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "link" + ], + "referencedColumns": [ + "link" + ] + } + ] + }, + { + "tableName": "plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repository` TEXT NOT NULL, `plugin_name` TEXT NOT NULL, `search_enabled` INTEGER, PRIMARY KEY(`repository`, `plugin_name`), FOREIGN KEY(`repository`) REFERENCES `repository_info`(`link`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repository", + "columnName": "repository", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "plugin_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "searchEnabled", + "columnName": "search_enabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repository", + "plugin_name" + ] + }, + "foreignKeys": [ + { + "table": "repository_info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repository" + ], + "referencedColumns": [ + "link" + ] + } + ] + }, + { + "tableName": "plugin_version", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`plugin_repository` TEXT NOT NULL, `plugin` TEXT NOT NULL, `version` REAL NOT NULL, `engine` REAL NOT NULL, `plugin_link` TEXT NOT NULL, `disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`plugin_repository`, `plugin`, `version`), FOREIGN KEY(`plugin_repository`, `plugin`) REFERENCES `plugin`(`repository`, `plugin_name`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repository", + "columnName": "plugin_repository", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "plugin", + "columnName": "plugin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "engine", + "columnName": "engine", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "plugin_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disabled", + "columnName": "disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "plugin_repository", + "plugin", + "version" + ] + }, + "foreignKeys": [ + { + "table": "plugin", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "plugin_repository", + "plugin" + ], + "referencedColumns": [ + "repository", + "plugin_name" + ] + } + ] + }, + { + "tableName": "remote_device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `is_default` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "remote_service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `device_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT, `password` TEXT, `type` INTEGER NOT NULL, `is_default` INTEGER NOT NULL, `api_token` TEXT NOT NULL, `field_1` TEXT NOT NULL, `field_2` TEXT NOT NULL, `field_3` TEXT NOT NULL, FOREIGN KEY(`device_id`) REFERENCES `remote_device`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "device", + "columnName": "device_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiToken", + "columnName": "api_token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldOne", + "columnName": "field_1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldTwo", + "columnName": "field_2", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldThree", + "columnName": "field_3", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "remote_device", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "device_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "complete_remote_service", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `username` TEXT, `password` TEXT, `type` INTEGER NOT NULL, `is_default` INTEGER NOT NULL, `api_token` TEXT NOT NULL, `field_1` TEXT NOT NULL, `field_2` TEXT NOT NULL, `field_3` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiToken", + "columnName": "api_token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldOne", + "columnName": "field_1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldTwo", + "columnName": "field_2", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldThree", + "columnName": "field_3", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, '0057d7c7f20f5a00ceeed824e378aee6')" + ] + } +} \ No newline at end of file diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml index b6c5551a..26a76b54 100644 --- a/app/app/src/main/AndroidManifest.xml +++ b/app/app/src/main/AndroidManifest.xml @@ -273,143 +273,108 @@ - - - - - + + - + - - - - - - - - - - + - - - - - - - + + + + + - - - - - + - - - - - - + + - - + - - + + - - - - - + + - - - - - - - - - @@ -420,19 +385,8 @@ - - - - - - - - - - - diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/view/AuthenticationFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/view/AuthenticationFragment.kt index c6179b92..5a6886b7 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/view/AuthenticationFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/view/AuthenticationFragment.kt @@ -101,10 +101,12 @@ class AuthenticationFragment : UnchainedFragment() { val action = AuthenticationFragmentDirections.actionAuthenticationToUser() findNavController().navigate(action) } + FSMAuthenticationState.AuthenticatedPrivateToken -> { val action = AuthenticationFragmentDirections.actionAuthenticationToUser() findNavController().navigate(action) } + FSMAuthenticationState.StartNewLogin -> { // reset the current data // token == null @@ -125,21 +127,26 @@ class AuthenticationFragment : UnchainedFragment() { // get the authentication link to start the process viewModel.fetchAuthenticationInfo() } + FSMAuthenticationState.WaitingUserConfirmation -> { // start the next auth step viewModel.fetchSecrets() } + FSMAuthenticationState.WaitingToken -> { viewModel.fetchToken() } + FSMAuthenticationState.CheckCredentials, FSMAuthenticationState.RefreshingOpenToken -> { // managed by activity } + is FSMAuthenticationState.WaitingUserAction -> { // todo: depending on the action required show an error or restart the // process } + FSMAuthenticationState.Start -> { // this shouldn't happen } @@ -148,28 +155,30 @@ class AuthenticationFragment : UnchainedFragment() { } // 1. start checking for the auth link - viewModel.authLiveData.observe( - viewLifecycleOwner, - EventObserver { auth -> - if (auth != null) { - binding.tvAuthenticationLink.text = auth.verificationUrl - binding.tvAuthenticationLink.visibility = View.VISIBLE - binding.cbLink.isChecked = true - binding.cbLink.text = getString(R.string.link_loaded) - // let the user copy the user code to enter in the website - binding.tvUserCodeValue.text = auth.userCode - binding.bCopyLink.isEnabled = true - // update the currently saved credentials - activityViewModel.updateCredentialsDeviceCode(auth.deviceCode) - // transition state machine + viewModel.authLiveData.observe(viewLifecycleOwner) { event -> + event?.peekContent()?.let { auth -> + binding.tvAuthenticationLink.text = auth.verificationUrl + binding.tvAuthenticationLink.visibility = View.VISIBLE + binding.cbLink.isChecked = true + binding.cbLink.text = getString(R.string.link_loaded) + // let the user copy the user code to enter in the website + binding.tvUserCodeValue.text = auth.userCode + binding.bCopyLink.isEnabled = true + // update the currently saved credentials + activityViewModel.updateCredentialsDeviceCode(auth.deviceCode) + // transition state machine + if ( + activityViewModel.getAuthenticationMachineState() + is FSMAuthenticationState.StartNewLogin + ) { activityViewModel.transitionAuthenticationMachine( FSMAuthenticationEvent.OnAuthLoaded ) // set up values for calling the secrets endpoint viewModel.setupSecretLoop(auth.expiresIn) } - }, - ) + } + } // 2. start checking for user confirmation viewModel.secretLiveData.observe( @@ -186,12 +195,14 @@ class AuthenticationFragment : UnchainedFragment() { FSMAuthenticationEvent.OnUserConfirmationMissing ) } + SecretResult.Expired -> { // will restart the authentication process activityViewModel.transitionAuthenticationMachine( FSMAuthenticationEvent.OnUserConfirmationExpired ) } + is SecretResult.Retrieved -> { if ( activityViewModel.getAuthenticationMachineState() diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/viewmodel/AuthenticationViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/viewmodel/AuthenticationViewModel.kt index 9c4320a4..100e41ea 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/viewmodel/AuthenticationViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/viewmodel/AuthenticationViewModel.kt @@ -81,9 +81,8 @@ constructor( * secrets endpoint */ fun setupSecretLoop(expiresIn: Int) { - // this is just an estimate, keeping track of time would be more precise. As of now this - // value - // should be 120 + // this is just an estimate, keeping track of time would be more precise. + // As of now this value should be 120 var calls = (expiresIn * 1000 / SECRET_CALLS_DELAY).toInt() - 10 // remove 10% of the calls to account for the api calls calls -= calls / 10 diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt index 242989a0..f0b060dd 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/MainActivity.kt @@ -30,6 +30,7 @@ import androidx.core.view.MenuProvider import androidx.core.view.forEach import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController +import androidx.navigation.NavGraph import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.navigateUp @@ -78,6 +79,7 @@ class MainActivity : AppCompatActivity() { private lateinit var navController: NavController private lateinit var appBarConfiguration: AppBarConfiguration + private var searchTabStartDestinationId: Int = R.id.pluginSearchFragment private var checkedUpdate: Boolean = false // Countly crash reporter set up. Debug mode only @@ -829,6 +831,17 @@ class MainActivity : AppCompatActivity() { navController = (supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment) .navController + + val searchGraphStartDestination = getSearchTabStartDestinationId() + val rootGraph = navController.navInflater.inflate(R.navigation.nav_graph) + val searchGraph = rootGraph.findNode(R.id.navigation_search) as? NavGraph + + if (searchGraph != null) { + searchGraph.setStartDestination(searchGraphStartDestination) + searchTabStartDestinationId = searchGraphStartDestination + } + navController.setGraph(rootGraph, intent.extras) + binding.bottomNavView.setupWithNavController(navController) // Setup the ActionBar with navController and 3 top level destinations @@ -841,6 +854,7 @@ class MainActivity : AppCompatActivity() { R.id.user_dest, R.id.list_tabs_dest, R.id.search_dest, + R.id.pluginSearchFragment, ) ) setupActionBarWithNavController(navController, appBarConfiguration) @@ -861,8 +875,8 @@ class MainActivity : AppCompatActivity() { } R.id.navigation_search -> { - if (currentDestination?.id != R.id.search_dest) { - navController.popBackStack(R.id.search_dest, false) + if (currentDestination?.id != searchTabStartDestinationId) { + navController.popBackStack(searchTabStartDestinationId, false) } } } @@ -870,6 +884,18 @@ class MainActivity : AppCompatActivity() { } } + private fun getSearchTabStartDestinationId(): Int { + return when ( + preferences.getString( + PreferenceKeys.Ui.SEARCH_START_DESTINATION_KEY, + PreferenceKeys.Ui.SearchStartDestination.PLUGINS, + ) + ) { + PreferenceKeys.Ui.SearchStartDestination.FILES -> R.id.search_dest + else -> R.id.pluginSearchFragment + } + } + @Deprecated("Deprecated in Java") override fun onBackPressed() { // fixme: not working good anymore on android 16, migrate @@ -889,7 +915,8 @@ class MainActivity : AppCompatActivity() { previousDestination.destination.id == R.id.authentication_dest || previousDestination.destination.id == R.id.start_dest || previousDestination.destination.id == R.id.user_dest || - previousDestination.destination.id == R.id.search_dest + previousDestination.destination.id == R.id.search_dest || + previousDestination.destination.id == R.id.pluginSearchFragment ) { // check if it has been 2 seconds since the last time we pressed back val pressedTime = System.currentTimeMillis() diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/UnchainedApplication.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/UnchainedApplication.kt index 48817f57..43d1f92a 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/UnchainedApplication.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/UnchainedApplication.kt @@ -6,9 +6,12 @@ import android.app.NotificationManager import android.content.Context import android.content.SharedPreferences import com.github.livingwithhippos.unchained.R +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteService import com.github.livingwithhippos.unchained.data.local.ProtoStore +import com.github.livingwithhippos.unchained.data.local.RemoteDeviceDao import com.github.livingwithhippos.unchained.data.local.RepositoryDataDao import com.github.livingwithhippos.unchained.data.model.Repository +import com.github.livingwithhippos.unchained.data.repository.ServiceRepository import com.github.livingwithhippos.unchained.utilities.DEFAULT_PLUGINS_REPOSITORY_LINK import com.github.livingwithhippos.unchained.utilities.TelemetryManager import dagger.hilt.android.HiltAndroidApp @@ -32,6 +35,8 @@ class UnchainedApplication : Application() { @Inject lateinit var protoStore: ProtoStore @Inject lateinit var pluginRepositoryDataDao: RepositoryDataDao + @Inject lateinit var legacyServiceRepository: RemoteDeviceDao + @Inject lateinit var newServiceRepository: ServiceRepository private val job = Job() private val scope = CoroutineScope(Dispatchers.Default + job) @@ -45,6 +50,7 @@ class UnchainedApplication : Application() { protoStore.deleteIncompleteCredentials() if (pluginRepositoryDataDao.getDefaultRepository().isEmpty()) pluginRepositoryDataDao.insert(Repository(DEFAULT_PLUGINS_REPOSITORY_LINK)) + migrateServices() } createNotificationChannels() @@ -52,6 +58,38 @@ class UnchainedApplication : Application() { TelemetryManager.onCreate(this) } + private suspend fun migrateServices() { + // todo: check migration in DatabaseModule + val legacyServices = legacyServiceRepository.getDevicesAndServices() + if (legacyServices.isEmpty()) return + + try { + print("Migrating ${legacyServices.values.size} services from legacy database") + legacyServices.forEach { (device, services) -> + newServiceRepository.insertAllServices( + services.map { + CompleteRemoteService( + id = 0, + name = it.name, + address = "${device.address}:${it.port}", + username = it.username, + password = it.password, + type = it.type, + isDefault = device.isDefault && it.isDefault, + apiToken = it.apiToken, + fieldOne = it.fieldOne, + fieldTwo = it.fieldTwo, + fieldThree = it.fieldThree, + ) + } + ) + } + legacyServiceRepository.deleteAll() + } catch (e: Exception) { + print("Error migrating services: ${e.message}") + } + } + private fun createNotificationChannels() { // Create the NotificationChannel, but only on API 26+ because // the NotificationChannel class is new and not in the support library diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/UnchainedFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/UnchainedFragment.kt index 13fd467a..cfc09538 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/UnchainedFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/UnchainedFragment.kt @@ -2,11 +2,33 @@ package com.github.livingwithhippos.unchained.base import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.navigation.NavDirections +import androidx.navigation.fragment.findNavController import com.github.livingwithhippos.unchained.start.viewmodel.MainActivityViewModel +import timber.log.Timber /** Base [Fragment] class, giving simple access to the activity ViewModel to its subclasses */ abstract class UnchainedFragment : Fragment() { // activity viewModel. To be used for alerting of expired token or missing network val activityViewModel: MainActivityViewModel by activityViewModels() + + fun safeNavigate(action: NavDirections): Boolean { + val nav = findNavController() + val current = nav.currentDestination + if (current != null && current.getAction(action.actionId) != null) { + try { + nav.navigate(action) + return true + } catch (e: IllegalArgumentException) { + Timber.w(e, "Safe navigate failed for actionId=${action.actionId}") + return false + } + } else { + Timber.w( + "Navigation action not found from destination ${current?.id} for actionId=${action.actionId}" + ) + return false + } + } } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/CompleteRemoteServiceDao.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/CompleteRemoteServiceDao.kt new file mode 100644 index 00000000..6b95c9c0 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/CompleteRemoteServiceDao.kt @@ -0,0 +1,63 @@ +package com.github.livingwithhippos.unchained.data.local + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface CompleteRemoteServiceDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertService(service: CompleteRemoteService): Long + + @Upsert suspend fun upsertService(service: CompleteRemoteService): Long + + @Delete suspend fun deleteService(service: CompleteRemoteService) + + @Query("DELETE FROM complete_remote_service WHERE id = :serviceID") + suspend fun deleteService(serviceID: Int) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllServices(list: List): List + + @Query("SELECT * FROM complete_remote_service") + suspend fun getServices(): List + + @Query("SELECT * FROM complete_remote_service WHERE complete_remote_service.type IN (:types)") + suspend fun getServicesTypes(types: List): List + + @Query("SELECT * FROM complete_remote_service WHERE complete_remote_service.type IN (:types)") + fun getServicesTypesFlow(types: List): Flow> + + @Query("SELECT id FROM complete_remote_service WHERE rowid = :rowId") + suspend fun getServiceIDByRow(rowId: Long): Int? + + @Query("SELECT * FROM complete_remote_service WHERE id = :serviceID") + suspend fun getService(serviceID: Int): CompleteRemoteService? + + @Query("DELETE FROM complete_remote_service") suspend fun deleteAll() + + @Query("DELETE FROM complete_remote_service WHERE id = :id") suspend fun removeService(id: Int) + + @Query( + "SELECT * from complete_remote_service WHERE complete_remote_service.is_default = 1 LIMIT 1" + ) + suspend fun getDefaultService(): RemoteDevice? + + @Query("UPDATE complete_remote_service SET is_default = CASE WHEN id = :id THEN 1 ELSE 0 END;") + suspend fun setDefaultService(id: Int) + + @Query("UPDATE complete_remote_service SET enabled = 1 WHERE name = :name") + suspend fun setDefault(name: String) + + @Query("UPDATE complete_remote_service SET enabled = :enabled WHERE id = :id") + suspend fun enableService(id: Int, enabled: Boolean) + + @Query( + "SELECT * FROM complete_remote_service WHERE complete_remote_service.type IN (:types) AND enabled = 1" + ) + fun getEnabledServicesTypes(types: List): List +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/CompleteRemoteServiceEntity.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/CompleteRemoteServiceEntity.kt new file mode 100644 index 00000000..26dd9578 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/CompleteRemoteServiceEntity.kt @@ -0,0 +1,47 @@ +package com.github.livingwithhippos.unchained.data.local + +import android.os.Parcelable +import androidx.annotation.Keep +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.Objects +import kotlinx.parcelize.Parcelize + +@Keep +@Parcelize +@Entity(tableName = "complete_remote_service") +class CompleteRemoteService( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Int, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "address") val address: String, + @ColumnInfo(name = "username") val username: String? = null, + @ColumnInfo(name = "password") val password: String? = null, + // service type, see [RemoteServiceType] + @ColumnInfo(name = "type") val type: Int, + @ColumnInfo(name = "is_default") val isDefault: Boolean = false, + // extra fields for future needs. Otherwise a new entity linked to this one should work + // or 1 entity per service type with customized fields + // or also storing a json object as string and parsing it back + // extra field example: api token + @ColumnInfo(name = "api_token") val apiToken: String = "", + @ColumnInfo(name = "enabled") val enabled: Boolean = true, + @ColumnInfo(name = "field_1") val fieldOne: String = "", + @ColumnInfo(name = "field_2") val fieldTwo: String = "", + @ColumnInfo(name = "field_3") val fieldThree: String = "", +) : Parcelable { + override fun equals(other: Any?): Boolean { + if (other is CompleteRemoteService) { + return other.id == id + } + return false + } + + override fun hashCode(): Int = Objects.hash(id) +} + +/** Helper class to have all the service details together */ +data class CompleteRemoteServiceDetails( + val service: CompleteRemoteService, + val type: RemoteServiceType, +) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/UnchaineDB.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/UnchaineDB.kt index 61f73bb5..09b9c068 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/UnchaineDB.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/UnchaineDB.kt @@ -22,14 +22,16 @@ import com.github.livingwithhippos.unchained.data.model.RepositoryPlugin PluginVersion::class, RemoteDevice::class, RemoteService::class, + CompleteRemoteService::class, ], - version = 7, + version = 10, exportSchema = true, autoMigrations = [ AutoMigration(from = 4, to = 5), AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7), + AutoMigration(from = 7, to = 8), ], ) abstract class UnchaineDB : RoomDatabase() { @@ -39,5 +41,7 @@ abstract class UnchaineDB : RoomDatabase() { abstract fun pluginRepositoryDao(): RepositoryDataDao - abstract fun pluginRemoteDeviceDao(): RemoteDeviceDao + abstract fun remoteDeviceDao(): RemoteDeviceDao + + abstract fun completeRemoteServiceDao(): CompleteRemoteServiceDao } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/PluginRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/PluginRepository.kt index 65a7aeb9..f9c82693 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/PluginRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/PluginRepository.kt @@ -67,4 +67,5 @@ data class PluginVersion( @ColumnInfo(name = "version") val version: Float, @ColumnInfo(name = "engine") val engine: Float, @ColumnInfo(name = "plugin_link") val link: String, + @ColumnInfo(name = "disabled", defaultValue = "0") val disabled: Boolean = false, ) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/jackett/Indexers.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/jackett/Indexers.kt index 23e91712..1714ddee 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/jackett/Indexers.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/jackett/Indexers.kt @@ -1,23 +1,52 @@ package com.github.livingwithhippos.unchained.data.model.jackett -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement import com.github.livingwithhippos.unchained.data.model.torznab.Capabilities +import com.github.livingwithhippos.unchained.data.model.torznab.parseCapabilities +import com.github.livingwithhippos.unchained.utilities.directChild +import com.github.livingwithhippos.unchained.utilities.directChildren +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.jsoup.Jsoup +import org.jsoup.parser.Parser -@JacksonXmlRootElement(localName = "indexers") -data class Indexers( - @param:JacksonXmlElementWrapper(useWrapping = false) - @param:JacksonXmlProperty(localName = "indexer") - val indexers: List -) +@Serializable data class Indexers(@SerialName("indexer") val indexers: List) +@Serializable data class Indexer( - @param:JacksonXmlProperty(isAttribute = true, localName = "id") val id: String, - @param:JacksonXmlProperty(isAttribute = true, localName = "configured") val configured: String, - @param:JacksonXmlProperty(localName = "title") val title: String, - @param:JacksonXmlProperty(localName = "description") val description: String, - @param:JacksonXmlProperty(localName = "link") val link: String, - @param:JacksonXmlProperty(localName = "type") val type: String, - @param:JacksonXmlProperty(localName = "caps") val capabilities: Capabilities, + val id: String, + val configured: String, + val title: String, + val description: String, + val link: String, + val type: String, + @SerialName("caps") val capabilities: Capabilities, ) + +fun parseIndexers(body: String): Indexers? { + + val document = Jsoup.parse(body, "", Parser.xmlParser()) + val indexers = + document.directChild("indexers") + ?: throw IllegalArgumentException("Missing indexers element") + val indexerList = + indexers.directChildren("indexer").map { indexerElement -> + val id = indexerElement.attr("id") + val configured = indexerElement.attr("configured") + val title = indexerElement.directChild("title") + val description = indexerElement.directChild("description") + val link = indexerElement.directChild("link") + val type = indexerElement.directChild("type") + + val capabilities = parseCapabilities(indexerElement.directChild("caps")) ?: return null + Indexer( + id = id, + configured = configured, + title = title?.text() ?: "", + description = description?.text() ?: "", + link = link?.text() ?: "", + type = type?.text() ?: "", + capabilities = capabilities, + ) + } + return Indexers(indexerList) +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZCapabilities.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZCapabilities.kt index 196d289b..861c98ab 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZCapabilities.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZCapabilities.kt @@ -1,58 +1,146 @@ package com.github.livingwithhippos.unchained.data.model.torznab -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement +import com.github.livingwithhippos.unchained.utilities.directChild +import com.github.livingwithhippos.unchained.utilities.directChildren +import kotlin.collections.ifEmpty +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.parser.Parser -@JacksonXmlRootElement(localName = "caps") +@Serializable data class Capabilities( - @param:JacksonXmlProperty(localName = "server") val server: Server, - @param:JacksonXmlProperty(localName = "limits") val limits: Limits, - @param:JacksonXmlProperty(localName = "searching") val searching: Searching, - @param:JacksonXmlProperty(localName = "categories") val categories: Categories, + val server: Server, + val limits: Limits, + val searching: Searching, + val categories: Categories, ) -data class Server( - @param:JacksonXmlProperty(isAttribute = true, localName = "title") val title: String -) +@Serializable data class Server(val title: String) -data class Limits( - @param:JacksonXmlProperty(isAttribute = true, localName = "default") val default: Int, - @param:JacksonXmlProperty(isAttribute = true, localName = "max") val max: Int, -) +@Serializable data class Limits(@SerialName("default") val default: Int, val max: Int) +@Serializable data class Searching( - @param:JacksonXmlProperty(localName = "search") val search: CapsSearch, - @param:JacksonXmlProperty(localName = "tv-search") val tvSearch: CapsSearch, - @param:JacksonXmlProperty(localName = "movie-search") val movieSearch: CapsSearch, - @param:JacksonXmlProperty(localName = "music-search") val musicSearch: CapsSearch, - @param:JacksonXmlProperty(localName = "audio-search") val audioSearch: CapsSearch, - @param:JacksonXmlProperty(localName = "book-search") val bookSearch: CapsSearch, + val search: CapsSearch, + @SerialName("tv-search") val tvSearch: CapsSearch, + @SerialName("movie-search") val movieSearch: CapsSearch, + @SerialName("music-search") val musicSearch: CapsSearch, + @SerialName("audio-search") val audioSearch: CapsSearch, + @SerialName("book-search") val bookSearch: CapsSearch, ) -data class CapsSearch( - @param:JacksonXmlProperty(isAttribute = true, localName = "available") val available: String, - @param:JacksonXmlProperty(isAttribute = true, localName = "supportedParams") - val supportedParams: String, - @param:JacksonXmlProperty(isAttribute = true, localName = "searchEngine") - val searchEngine: String?, -) +@Serializable +data class CapsSearch(val available: String, val supportedParams: String, val searchEngine: String?) -data class Categories( - @param:JacksonXmlElementWrapper(useWrapping = false) - @param:JacksonXmlProperty(localName = "category") - val category: List -) +@Serializable data class Categories(@SerialName("category") val category: List) +@Serializable data class Category( - @param:JacksonXmlProperty(isAttribute = true, localName = "id") val id: Int, - @param:JacksonXmlProperty(isAttribute = true, localName = "name") val name: String, - @param:JacksonXmlElementWrapper(useWrapping = false) - @param:JacksonXmlProperty(localName = "subcat") - val subcat: List?, + val id: Int, + val name: String, + @SerialName("subcat") val subcat: List? = null, ) -data class SubCategory( - @param:JacksonXmlProperty(isAttribute = true, localName = "id") val id: Int, - @param:JacksonXmlProperty(isAttribute = true, localName = "name") val name: String, -) +@Serializable data class SubCategory(val id: Int, val name: String) + +fun parseCapabilities(element: Element?): Capabilities? { + if (element == null) return null + + val capabilities: Capabilities = element.let { + val serverTitle = it.directChild("server")?.attr("title") ?: "" + val limitsDefault = it.directChild("limits")?.attr("default") ?: "0" + val limitsMax = it.directChild("limits")?.attr("max") ?: "0" + + val searchingElement = + it.directChild("searching") + ?: throw IllegalArgumentException("Missing searching element") + + val search = searchingElement.directChild("search") + val tvSearch = searchingElement.directChild("tv-search") + val movieSearch = searchingElement.directChild("movie-search") + val musicSearch = searchingElement.directChild("music-search") + val audioSearch = searchingElement.directChild("audio-search") + val bookSearch = searchingElement.directChild("book-search") + + Capabilities( + server = Server(title = serverTitle), + limits = + Limits( + default = limitsDefault.toIntOrNull() ?: 0, + max = limitsMax.toIntOrNull() ?: 0, + ), + searching = + Searching( + search = + CapsSearch( + available = search?.attr("available") ?: "no", + supportedParams = search?.attr("supportedParams") ?: "", + searchEngine = search?.attr("searchEngine"), + ), + tvSearch = + CapsSearch( + available = tvSearch?.attr("available") ?: "no", + supportedParams = tvSearch?.attr("supportedParams") ?: "", + searchEngine = tvSearch?.attr("searchEngine"), + ), + movieSearch = + CapsSearch( + available = movieSearch?.attr("available") ?: "no", + supportedParams = movieSearch?.attr("supportedParams") ?: "", + searchEngine = movieSearch?.attr("searchEngine"), + ), + musicSearch = + CapsSearch( + available = musicSearch?.attr("available") ?: "no", + supportedParams = musicSearch?.attr("supportedParams") ?: "", + searchEngine = musicSearch?.attr("searchEngine"), + ), + audioSearch = + CapsSearch( + available = audioSearch?.attr("available") ?: "no", + supportedParams = audioSearch?.attr("supportedParams") ?: "", + searchEngine = audioSearch?.attr("searchEngine"), + ), + bookSearch = + CapsSearch( + available = bookSearch?.attr("available") ?: "no", + supportedParams = bookSearch?.attr("supportedParams") ?: "", + searchEngine = bookSearch?.attr("searchEngine"), + ), + ), + categories = + Categories( + category = + element.directChild("categories")?.directChildren("category")?.mapNotNull { + categoryElement -> + val categoryId = + categoryElement.attr("id").toIntOrNull() ?: return@mapNotNull null + val categoryName = categoryElement.attr("name") + val subcategories = + categoryElement.directChildren("subcat").map { subcatElement -> + SubCategory( + id = subcatElement.attr("id").toIntOrNull() ?: -1, + name = subcatElement.attr("name"), + ) + } + Category( + id = categoryId, + name = categoryName, + subcat = subcategories.ifEmpty { null }, + ) + } ?: emptyList() + ), + ) + } + + return capabilities +} + +fun parseCapabilities(body: String): Capabilities? { + val document = Jsoup.parse(body, "", Parser.xmlParser()) + val capsElement = document.directChild("caps") ?: return null + + return parseCapabilities(capsElement) +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZSearch.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZSearch.kt index f1d93e7d..f8b85822 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZSearch.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZSearch.kt @@ -1,45 +1,130 @@ package com.github.livingwithhippos.unchained.data.model.torznab -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement +import android.content.Context +import com.github.livingwithhippos.unchained.plugins.model.ScrapedItem +import com.github.livingwithhippos.unchained.utilities.directChild +import com.github.livingwithhippos.unchained.utilities.directChildText +import com.github.livingwithhippos.unchained.utilities.directChildren +import com.github.livingwithhippos.unchained.utilities.extension.getFileSizeString +import com.github.livingwithhippos.unchained.utilities.parseCommonSize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.parser.Parser +import timber.log.Timber -@JacksonXmlRootElement(localName = "rss") -data class SearchRSS(@param:JacksonXmlProperty(localName = "channel") val channel: Channel) +@Serializable data class SearchRSS(val version: String, val channel: Channel) +@Serializable data class Channel( - @param:JacksonXmlProperty(localName = "title") val title: String, - @param:JacksonXmlProperty(localName = "description") val description: String, - @param:JacksonXmlProperty(localName = "link") val link: String, - @param:JacksonXmlProperty(localName = "language") val language: String, - @param:JacksonXmlProperty(localName = "category") val category: String, - @param:JacksonXmlProperty(localName = "item") val items: List, + val title: String, + val description: String, + val link: String, + val language: String, + val category: String, + @SerialName("item") val items: List, ) +@Serializable data class Item( - @param:JacksonXmlProperty(localName = "title") val title: String, - @param:JacksonXmlProperty(localName = "guid") val guid: String, - @param:JacksonXmlProperty(localName = "type") val type: String, - @param:JacksonXmlProperty(localName = "comments") val comments: String, - @param:JacksonXmlProperty(localName = "pubDate") val pubDate: String, - @param:JacksonXmlProperty(localName = "size") val size: String, - @param:JacksonXmlProperty(localName = "description") val description: String, - @param:JacksonXmlProperty(localName = "link") val link: String, - @param:JacksonXmlProperty(localName = "category") val categories: List, - @param:JacksonXmlProperty(localName = "enclosure") val enclosure: Enclosure, + val title: String, + val guid: String, + val type: String, + val comments: String, + val pubDate: String, + val size: String, + val description: String, + val link: String, + @SerialName("category") val categories: List, + val enclosure: Enclosure, // todo: check what happens with empty responses, nullable? default emptyList()? - @param:JacksonXmlElementWrapper(useWrapping = false) - @param:JacksonXmlProperty(namespace = "torznab", localName = "attr") - val torznabAttributes: List, + val torznabAttributes: List = emptyList(), ) -data class Enclosure( - @param:JacksonXmlProperty(isAttribute = true, localName = "url") val url: String, - @param:JacksonXmlProperty(isAttribute = true, localName = "length") val length: String, - @param:JacksonXmlProperty(isAttribute = true, localName = "type") val type: String, -) +@Serializable data class Enclosure(val url: String, val length: String, val type: String) -data class TorznabAttribute( - @param:JacksonXmlProperty(isAttribute = true, localName = "name") val name: String, - @param:JacksonXmlProperty(isAttribute = true, localName = "value") val value: String, -) +@Serializable data class TorznabAttribute(val name: String, val value: String) + +fun parseSearchRss(body: String): SearchRSS { + val document = Jsoup.parse(body, "", Parser.xmlParser()) + val rss = + document.getElementsByTag("rss").firstOrNull() + ?: throw IllegalArgumentException("Missing rss element") + val channel = rss.directChild("channel") ?: throw IllegalArgumentException("Missing channel") + + return SearchRSS( + version = rss.attr("version"), + channel = + Channel( + title = channel.directChildText("title"), + description = channel.directChildText("description"), + link = channel.directChildText("link"), + language = channel.directChildText("language"), + category = channel.directChildText("category"), + items = channel.directChildren("item").map(::parseItem), + ), + ) +} + +private fun parseItem(item: Element): Item { + val enclosure = item.directChild("enclosure") + + return Item( + title = item.directChildText("title"), + guid = item.directChildText("guid"), + type = item.directChildText("type"), + comments = item.directChildText("comments"), + pubDate = item.directChildText("pubDate"), + size = item.directChildText("size"), + description = item.directChildText("description"), + link = item.directChildText("link"), + categories = item.directChildren("category").map(Element::text), + enclosure = + Enclosure( + url = enclosure?.attr("url").orEmpty(), + length = enclosure?.attr("length").orEmpty(), + type = enclosure?.attr("type").orEmpty(), + ), + torznabAttributes = + item.directChildren("torznab:attr").map { + TorznabAttribute(name = it.attr("name"), value = it.attr("value")) + }, + ) +} + +fun rssToScrapedItems(context: Context, rss: SearchRSS): Pair, Int> { + var errors = 0 + val scraped = mutableListOf() + rss.channel.items.forEach { item -> + try { + val sizeLong = item.size.toLongOrNull() + scraped.add( + ScrapedItem( + name = item.title, + link = item.comments, + seeders = + item.torznabAttributes + .firstOrNull { it.name.equals("seeders", ignoreCase = true) } + ?.value, + leechers = + item.torznabAttributes + .firstOrNull { it.name.equals("leechers", ignoreCase = true) } + ?.value, + size = + if (sizeLong !== null) getFileSizeString(context, sizeLong) else item.size, + addedDate = item.pubDate, + parsedSize = parseCommonSize(item.size), + // todo: add better recognition of links + magnets = listOf(item.link), + torrents = emptyList(), + hosting = emptyList(), + ) + ) + } catch (ex: Exception) { + Timber.e(ex) + errors++ + } + } + return Pair(scraped, errors) +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/DatabasePluginRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/DatabasePluginRepository.kt index 8724a349..f440131e 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/DatabasePluginRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/DatabasePluginRepository.kt @@ -69,6 +69,7 @@ constructor(private val repositoryDataDao: RepositoryDataDao) { version = it.plugin, engine = it.engine, link = it.link, + disabled = plugin.disabled ?: false, ) } ) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/JackettRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/JackettRepository.kt index c219230d..4995ef8a 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/JackettRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/JackettRepository.kt @@ -1,74 +1,69 @@ package com.github.livingwithhippos.unchained.data.repository +import android.content.Context import android.net.Uri import androidx.core.net.toUri -import com.fasterxml.jackson.module.kotlin.readValue +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteService import com.github.livingwithhippos.unchained.data.model.jackett.Indexers +import com.github.livingwithhippos.unchained.data.model.jackett.parseIndexers import com.github.livingwithhippos.unchained.data.model.torznab.Capabilities import com.github.livingwithhippos.unchained.data.model.torznab.SearchRSS +import com.github.livingwithhippos.unchained.data.model.torznab.parseCapabilities +import com.github.livingwithhippos.unchained.data.model.torznab.parseSearchRss +import com.github.livingwithhippos.unchained.data.model.torznab.rssToScrapedItems import com.github.livingwithhippos.unchained.di.ClassicClient +import com.github.livingwithhippos.unchained.plugins.ParserResult import com.github.livingwithhippos.unchained.utilities.EitherResult -import com.github.livingwithhippos.unchained.utilities.xml.xmlMapper +import dagger.hilt.android.qualifiers.ApplicationContext import java.io.IOException -import java.net.URISyntaxException import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import timber.log.Timber -class JackettRepository @Inject constructor(@param:ClassicClient private val client: OkHttpClient) { +class JackettRepository +@Inject +constructor( + @param:ClassicClient private val client: OkHttpClient, + @ApplicationContext private val applicationContext: Context, +) { - private fun getBasicBuilder( - baseUrl: String, - port: Int = 9117, - apiKey: String, + private fun getBasicApi( + service: CompleteRemoteService, indexersFilter: String = "all", - useSecureHttp: Boolean = false, ): Uri.Builder? { - var existingUri: Uri = - try { - "$baseUrl:$port".toUri() - } catch (ex: Exception) { - Timber.e(ex, "Error parsing url: $baseUrl:$port") + Timber.d(service.address) + return try { + val baseUri = service.address.toUri() + if (baseUri.scheme.isNullOrBlank() || baseUri.encodedAuthority.isNullOrBlank()) { return null } - if ( - !(existingUri.scheme.equals("http", ignoreCase = true) || - existingUri.scheme.equals("https", ignoreCase = true)) - ) { - existingUri = "${if (useSecureHttp) "https" else "http"}://$baseUrl:$port".toUri() + baseUri + .buildUpon() + .encodedQuery(null) + .fragment(null) + .appendPath("api") + .appendPath("v2.0") + .appendPath("indexers") + .appendPath(indexersFilter) + .appendPath("results") + .appendPath("torznab") + .appendPath("api") + .appendQueryParameter("apikey", service.apiToken) + } catch (ex: Exception) { + Timber.e(ex, "Error parsing url from $service") + null } - val baseBuilder: Uri.Builder = - try { - val builder = - Uri.Builder() - .scheme(existingUri.scheme) - .encodedAuthority(existingUri.encodedAuthority) - - builder - } catch (ex: URISyntaxException) { - Timber.e(ex, "Error parsing url: $baseUrl:$port") - null - } ?: return null - return baseBuilder - .appendPath("api") - .appendPath("v2.0") - .appendPath("indexers") - .appendPath(indexersFilter) - .appendPath("results") - .appendPath("torznab") - .appendPath("api") - .appendQueryParameter("apikey", apiKey) } - suspend fun performSearch( - baseUrl: String, - port: Int = 9117, + fun performSearch( + service: CompleteRemoteService, indexer: String = "all", - apiKey: String, query: String, categories: String = "", attributes: String? = null, @@ -84,79 +79,70 @@ class JackettRepository @Inject constructor(@param:ClassicClient private val cli album: String? = null, artist: String? = null, publisher: String? = null, - ): EitherResult = - withContext(Dispatchers.IO) { - val builder = - getBasicBuilder(baseUrl, port, apiKey, indexer) - ?: return@withContext EitherResult.Failure( - IllegalArgumentException("Impossible to parse url") - ) - - if (mediaType == null) builder.appendQueryParameter("t", "search") - else builder.appendQueryParameter("t", mediaType.value) - - // the search fails with no "cat" element, even if empty - builder.appendQueryParameter("cat", categories) - - if (attributes != null) builder.appendQueryParameter("attrs", attributes) - - if (extended != null) - builder.appendQueryParameter("extended", if (extended) "1" else "0") - - if (offset != null) builder.appendQueryParameter("offset", offset.toString()) - - if (limit != null) builder.appendQueryParameter("limit", limit.toString()) - - if (limit != null) builder.appendQueryParameter("year", year) - - if (season != null) builder.appendQueryParameter("season", season) - - if (episodes != null) builder.appendQueryParameter("ep", episodes) + ) = + flow { + val builder = getBasicApi(service, indexer) + if (builder == null) { + emit(ParserResult.SourceError) + return@flow + } + if (mediaType == null) builder.appendQueryParameter("t", "search") + else builder.appendQueryParameter("t", mediaType.value) - if (genre != null) builder.appendQueryParameter("genre", genre.value) + // the search fails with no "cat" element, even if empty + builder.appendQueryParameter("cat", categories) - if (imdb != null) builder.appendQueryParameter("imdbid", imdb) + if (attributes != null) builder.appendQueryParameter("attrs", attributes) - if (album != null) builder.appendQueryParameter("album", album) + if (extended != null) + builder.appendQueryParameter("extended", if (extended) "1" else "0") - if (artist != null) builder.appendQueryParameter("artist", artist) + if (offset != null) builder.appendQueryParameter("offset", offset.toString()) + if (limit != null) builder.appendQueryParameter("limit", limit.toString()) + if (year != null) builder.appendQueryParameter("year", year) + if (season != null) builder.appendQueryParameter("season", season) + if (episodes != null) builder.appendQueryParameter("ep", episodes) + if (genre != null) builder.appendQueryParameter("genre", genre.value) + if (imdb != null) builder.appendQueryParameter("imdbid", imdb) + if (album != null) builder.appendQueryParameter("album", album) + if (artist != null) builder.appendQueryParameter("artist", artist) + if (publisher != null) builder.appendQueryParameter("publisher", publisher) - if (publisher != null) builder.appendQueryParameter("publisher", publisher) + builder.appendQueryParameter("q", query) - builder.appendQueryParameter("q", query) + val request = Request.Builder().url(builder.build().toString()).build() - val request = Request.Builder().url(builder.build().toString()).build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + emit(ParserResult.SourceError) + return@flow + } + if (response.body == null) { + emit(ParserResult.NetworkBodyError) + return@flow + } + val body: String = response.body.string() + try { + val search: SearchRSS = parseSearchRss(body) + val items = rssToScrapedItems(applicationContext, search) + emit(ParserResult.Results(items.first)) + return@flow + } catch (ex: Exception) { + Timber.e(ex, "Error parsing Search response") + } - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) - return@withContext EitherResult.Failure( - IOException("Unexpected http code $response") - ) - val body: String = - response.body?.string() - ?: return@withContext EitherResult.Failure( - IOException("Unexpected empty body") - ) - try { - val search = xmlMapper.readValue(body) - return@withContext EitherResult.Success(search) - } catch (ex: Exception) { - Timber.e(ex, "Error parsing Search response") + emit(ParserResult.SourceError) } - - return@withContext EitherResult.Failure(IOException("Unexpected search failure")) } - } + .flowOn(Dispatchers.IO) suspend fun getCapabilities( - baseUrl: String, - port: Int = 9117, - apiKey: String, + service: CompleteRemoteService, indexer: String = "all", ): EitherResult = withContext(Dispatchers.IO) { val builder = - getBasicBuilder(baseUrl, port, apiKey, indexer) + getBasicApi(service, indexer) ?: return@withContext EitherResult.Failure( IllegalArgumentException("Impossible to parse url") ) @@ -175,7 +161,11 @@ class JackettRepository @Inject constructor(@param:ClassicClient private val cli IOException("Unexpected empty body") ) try { - val capabilities = xmlMapper.readValue(body) + val capabilities: Capabilities = + parseCapabilities(body) + ?: return@withContext EitherResult.Failure( + IOException("Unexpected empty capabilities") + ) return@withContext EitherResult.Success(capabilities) } catch (ex: Exception) { Timber.e(ex, "Error parsing Capabilities response") @@ -188,20 +178,13 @@ class JackettRepository @Inject constructor(@param:ClassicClient private val cli } suspend fun getValidIndexers( - baseUrl: String, - port: Int = 9117, - apiKey: String, + service: CompleteRemoteService, configured: Boolean = true, ): EitherResult = withContext(Dispatchers.IO) { try { val builder = - getBasicBuilder( - baseUrl, - port, - apiKey, - indexersFilter = "!status:failing,test:passed", - ) + getBasicApi(service, indexersFilter = "!status:failing,test:passed") ?: return@withContext EitherResult.Failure( IllegalArgumentException("Impossible to parse url") ) @@ -223,7 +206,11 @@ class JackettRepository @Inject constructor(@param:ClassicClient private val cli IOException("Unexpected empty body") ) try { - val indexers = xmlMapper.readValue(body) + val indexers: Indexers = + parseIndexers(body) + ?: return@withContext EitherResult.Failure( + IOException("Unexpected empty indexers") + ) return@withContext EitherResult.Success(indexers) } catch (ex: Exception) { Timber.e(ex, "Error parsing indexers response") diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt index ede61070..cf03d8dd 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt @@ -106,6 +106,40 @@ constructor(protoStore: ProtoStore, @param:ClassicClient private val client: OkH } } + suspend fun openUrl( + address: String, + url: String, + username: String? = null, + password: String? = null, + ): KodiResponse? { + + try { + val kodiApiHelper: KodiApiHelper = + if (address.endsWith("/")) provideApiHelper(address) + else provideApiHelper("$address/") + + val kodiResponse = + safeApiCall( + call = { + kodiApiHelper.openUrl( + request = + KodiRequest( + method = "Player.Open", + params = KodiParams(item = KodiItem(fileUrl = url)), + ), + auth = encodeAuthentication(username, password), + ) + }, + errorMessage = "Error Sending url to Kodi", + ) + + return kodiResponse + } catch (e: Exception) { + Timber.e(e) + return null + } + } + private fun encodeAuthentication(username: String?, password: String?): String? { return if (!username.isNullOrBlank() && !password.isNullOrBlank()) { "Basic " + diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/PluginRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/PluginRepository.kt index 2512a991..e8ab875f 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/PluginRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/PluginRepository.kt @@ -28,7 +28,7 @@ class PluginRepository @Inject constructor() { manuallyInstalledOnly: Boolean = false, ): Pair, Int> = withContext(Dispatchers.IO) { - val pluginFiles = mutableListOf() + val pluginFiles = mutableListOf>() val pluginFolder = context.getDir("plugins", Context.MODE_PRIVATE) if (pluginFolder.exists()) { if (manuallyInstalledOnly) { @@ -36,14 +36,15 @@ class PluginRepository @Inject constructor() { if (localPluginFolder.exists()) { pluginFolder.walk().forEach { if (it.isFile && it.name.endsWith(TYPE_UNCHAINED, ignoreCase = true)) { - pluginFiles.add(it) + pluginFiles.add(Pair(MANUAL_PLUGINS_REPOSITORY_NAME, it)) } } } } else { pluginFolder.walk().forEach { + // for no these are the full path of the directory if (it.isFile && it.name.endsWith(TYPE_UNCHAINED, ignoreCase = true)) { - pluginFiles.add(it) + pluginFiles.add(Pair(it.parent ?: "unparsed_repository", it)) } } } @@ -53,11 +54,14 @@ class PluginRepository @Inject constructor() { var errors = 0 - for (file in pluginFiles) { + for (pluginPair in pluginFiles) { try { - val json = file.readText() + val json = pluginPair.second.readText() val plugin: Plugin? = pluginAdapter.fromJson(json) - if (plugin != null) plugins.add(plugin) else errors++ + if (plugin != null) { + plugin.repository = pluginPair.first + plugins.add(plugin) + } else errors++ } catch (ex: Exception) { Timber.e("Exception while parsing json plugin: $ex") } @@ -71,41 +75,42 @@ class PluginRepository @Inject constructor() { /** list of plugin files associated with the repository folder name */ - // note: the actual folder name may be different from the one put here. For example it - // could - // get changed to "app_plugins" + // note: the actual folder name may be different from the one put here. For example, it + // could get changed to "app_plugins" // it is still deterministic val pluginFolder = context.getDir("plugins", Context.MODE_PRIVATE) val pluginRepoFileAssociation = mutableMapOf>() if (pluginFolder.exists()) { - val files = mutableListOf() - var repoFolder = "" + var currentFolder = "" + val currentPluginList = mutableListOf() pluginFolder.walk().forEachIndexed { index, currentFile -> // skip the first folder, which is the "plugins" folder itself if (index > 0) { if (currentFile.isDirectory) { - if (repoFolder != "") { + if (currentPluginList.isNotEmpty()) { // finished scanning a directory and passing to a new one - pluginRepoFileAssociation[repoFolder] = files - // todo: check if this changes the saved one - files.clear() + // important! if we don't use toList() we'll have issues with its + // reference! + pluginRepoFileAssociation[currentFolder] = + currentPluginList.toList() + currentPluginList.clear() } - repoFolder = currentFile.name - Timber.d("Found folder $repoFolder") + currentFolder = currentFile.name + Timber.d("Found folder $currentFolder") } else if ( currentFile.isFile && currentFile.name.endsWith(TYPE_UNCHAINED, ignoreCase = true) ) { Timber.d("Found plugin ${currentFile.name}") - files.add(currentFile) + currentPluginList.add(currentFile) } else { Timber.w("Unknown file found: ${currentFile.name}") } } } // we don't pass through the last save in the forEach - if (files.isNotEmpty() && repoFolder != "") { - pluginRepoFileAssociation[repoFolder] = files + if (currentPluginList.isNotEmpty()) { + pluginRepoFileAssociation[currentFolder] = currentPluginList } } @@ -124,7 +129,10 @@ class PluginRepository @Inject constructor() { try { val json = pluginFile.readText() val plugin: Plugin? = pluginAdapter.fromJson(json) - if (plugin != null) repoList.add(plugin) else errors++ + if (plugin != null) { + plugin.repository = repoName + repoList.add(plugin) + } else errors++ } catch (ex: Exception) { Timber.e("Exception while parsing json plugin: $ex") errors++ diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/RemoteRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/RemoteRepository.kt index 9698da80..e7d5fbe9 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/RemoteRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/RemoteRepository.kt @@ -47,4 +47,30 @@ class RemoteRepository @Inject constructor(@param:ClassicClient private val clie return@withContext EitherResult.Success(true) } } + + suspend fun openUrl( + baseUrl: String, + url: String, + username: String? = null, + password: String? = null, + ): EitherResult = + withContext(Dispatchers.IO) { + val credential = okhttp3.Credentials.basic(username ?: "", password ?: "") + val newBaseUrl = if (baseUrl.endsWith("/")) baseUrl.dropLast(1) else baseUrl + val request = + Request.Builder() + .url("$newBaseUrl/requests/status.xml?command=in_play&input=$url") + .header("Authorization", credential) + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) + return@withContext EitherResult.Failure( + IOException("Unexpected http code $response") + ) + + Timber.d(response.body!!.string()) + return@withContext EitherResult.Success(true) + } + } } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/ServiceRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/ServiceRepository.kt new file mode 100644 index 00000000..fb08ad10 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/ServiceRepository.kt @@ -0,0 +1,55 @@ +package com.github.livingwithhippos.unchained.data.repository + +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteService +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteServiceDao +import com.github.livingwithhippos.unchained.data.local.RemoteDevice +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +class ServiceRepository @Inject constructor(private val serviceDao: CompleteRemoteServiceDao) { + + suspend fun insertService(service: CompleteRemoteService): Long = + serviceDao.insertService(service) + + suspend fun upsertService(service: CompleteRemoteService): Long = + serviceDao.upsertService(service) + + suspend fun deleteService(service: CompleteRemoteService) = serviceDao.deleteService(service) + + suspend fun deleteService(serviceID: Int) = serviceDao.deleteService(serviceID) + + suspend fun insertAllServices(list: List): List = + serviceDao.insertAllServices(list) + + suspend fun getServices(): List = serviceDao.getServices() + + suspend fun getServicesTypes(types: List): List = + serviceDao.getServicesTypes(types) + + fun getServicesTypesFlow(types: List): Flow> = + serviceDao.getServicesTypesFlow(types) + + suspend fun getServiceIDByRow(rowId: Long): Int? = serviceDao.getServiceIDByRow(rowId) + + suspend fun getService(serviceID: Int): CompleteRemoteService? = + serviceDao.getService(serviceID) + + suspend fun deleteAll() = serviceDao.deleteAll() + + suspend fun removeService(id: Int) = serviceDao.removeService(id) + + suspend fun getDefaultService(): RemoteDevice? = serviceDao.getDefaultService() + + suspend fun setDefaultService(id: Int) = serviceDao.setDefaultService(id) + + suspend fun setDefault(name: String) = serviceDao.setDefault(name) + + suspend fun enableService(id: Int, enabled: Boolean) = serviceDao.enableService(id, enabled) + + suspend fun getEnabledServicesTypes(types: List): List = + withContext(Dispatchers.IO) { + return@withContext serviceDao.getEnabledServicesTypes(types) + } +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/service/ForegroundTorrentService.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/service/ForegroundTorrentService.kt index f67e3942..bb600e6e 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/service/ForegroundTorrentService.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/service/ForegroundTorrentService.kt @@ -12,6 +12,7 @@ import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.TaskStackBuilder +import androidx.core.content.edit import androidx.lifecycle.LifecycleService import androidx.lifecycle.MutableLiveData import androidx.lifecycle.lifecycleScope @@ -88,8 +89,9 @@ class ForegroundTorrentService : LifecycleService() { val oldTorrentsIDs: Set = preferences.getStringSet(KEY_OBSERVED_TORRENTS, emptySet()) as Set // their updated status - val newLoadingTorrents = - list.filter { torrent -> loadingStatusList.contains(torrent.status) } + val newLoadingTorrents = list.filter { torrent -> + loadingStatusList.contains(torrent.status.lowercase()) + } // the torrent whose status is not a loading one anymore. val finishedTorrents = list @@ -101,7 +103,7 @@ class ForegroundTorrentService : LifecycleService() { // the new torrents to add to the notification system val unwatchedTorrents = newLoadingTorrents.filter { !oldTorrentsIDs.contains(it.id) } // the torrents not in our updated list anymore. These needs to be retrieved and analyzed singularly. - // Shouldn't happen often since there is a limit on how many active torrents you can have in real debrid + // Shouldn't happen often since there is a limit on how many active torrents you can have in real-debrid, // and we retrieve the last 30 torrents every time val missingTorrents = oldTorrentsIDs.filter { id -> !list.map { it.id }.contains(id) @@ -114,10 +116,7 @@ class ForegroundTorrentService : LifecycleService() { // update the torrents id to observe val newIDs = mutableSetOf() newIDs.addAll(newLoadingTorrents.map { it.id }) - with(preferences.edit()) { - putStringSet(KEY_OBSERVED_TORRENTS, newIDs) - apply() - } + preferences.edit { putStringSet(KEY_OBSERVED_TORRENTS, newIDs) } updateTiming = if (newIDs.isEmpty()) UPDATE_TIMING_LONG else UPDATE_TIMING_SHORT // let's first operate as if all the needed torrents were always in the list @@ -176,8 +175,9 @@ class ForegroundTorrentService : LifecycleService() { ) { // if there are no active torrents and the services has been started // for at least some minutes, stop the service - val unfinishedTorrents = - torrentList.count { loadingStatusList.contains(it.status) } + val unfinishedTorrents = torrentList.count { + loadingStatusList.contains(it.status) + } if (unfinishedTorrents == 0) { Timber.i( "Service has been running and no torrents are active, stopping it." diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/di/ApiFactory.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/di/ApiFactory.kt index c6aa14e5..531e94aa 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/di/ApiFactory.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/di/ApiFactory.kt @@ -63,6 +63,7 @@ object ApiFactory { if (BuildConfig.DEBUG) { val logInterceptor: HttpLoggingInterceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } + // HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.HEADERS } return OkHttpClient() .newBuilder() @@ -115,6 +116,7 @@ object ApiFactory { val logInterceptor: HttpLoggingInterceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } + // HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.HEADERS } OkHttpClient() .newBuilder() diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/di/DatabaseModule.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/di/DatabaseModule.kt index 78d0d545..9b975048 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/di/DatabaseModule.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/di/DatabaseModule.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.room.Room import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteServiceDao import com.github.livingwithhippos.unchained.data.local.HostRegexDao import com.github.livingwithhippos.unchained.data.local.KodiDeviceDao import com.github.livingwithhippos.unchained.data.local.RemoteDeviceDao @@ -26,7 +27,13 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext appContext: Context): UnchaineDB { return Room.databaseBuilder(appContext, UnchaineDB::class.java, "unchained_db") - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_8_9, + MIGRATION_9_10, + ) .build() } @@ -42,7 +49,7 @@ object DatabaseModule { @Provides fun provideRemoteDeviceDao(database: UnchaineDB): RemoteDeviceDao { - return database.pluginRemoteDeviceDao() + return database.remoteDeviceDao() } @Provides @@ -50,6 +57,11 @@ object DatabaseModule { return database.pluginRepositoryDao() } + @Provides + fun provideCompleteServiceDao(database: UnchaineDB): CompleteRemoteServiceDao { + return database.completeRemoteServiceDao() + } + private val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { @@ -74,4 +86,22 @@ object DatabaseModule { db.execSQL("DROP TABLE credentials") } } + + private val MIGRATION_8_9 = + object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE plugin_version ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0" + ) + } + } + + private val MIGRATION_9_10 = + object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE complete_remote_service ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1" + ) + } + } } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/model/ServicePickerAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/model/ServicePickerAdapter.kt index c71a109a..dcce7803 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/model/ServicePickerAdapter.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/model/ServicePickerAdapter.kt @@ -7,33 +7,36 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.github.livingwithhippos.unchained.R -import com.github.livingwithhippos.unchained.data.local.RemoteServiceDetails +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteServiceDetails import com.github.livingwithhippos.unchained.databinding.ItemListServicePickerBinding import com.github.livingwithhippos.unchained.utilities.extension.setDrawableByServiceType class ServicePickerAdapter(private val listener: ServicePickerListener) : - ListAdapter(DiffCallback()) { + ListAdapter(DiffCallback()) { - class DiffCallback : DiffUtil.ItemCallback() { + class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: RemoteServiceDetails, - newItem: RemoteServiceDetails, + oldItem: CompleteRemoteServiceDetails, + newItem: CompleteRemoteServiceDetails, ): Boolean = oldItem.service.id == newItem.service.id // content does not change on update override fun areContentsTheSame( - oldItem: RemoteServiceDetails, - newItem: RemoteServiceDetails, + oldItem: CompleteRemoteServiceDetails, + newItem: CompleteRemoteServiceDetails, ): Boolean = true } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RemoteServiceViewHolder { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): CompleteRemoteServiceViewHolder { val binding = ItemListServicePickerBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return RemoteServiceViewHolder(binding, listener) + return CompleteRemoteServiceViewHolder(binding, listener) } - override fun onBindViewHolder(holder: RemoteServiceViewHolder, position: Int) { + override fun onBindViewHolder(holder: CompleteRemoteServiceViewHolder, position: Int) { val item = getItem(position) holder.bindCell(item) } @@ -41,22 +44,22 @@ class ServicePickerAdapter(private val listener: ServicePickerListener) : override fun getItemViewType(position: Int) = R.layout.item_list_service_picker } -class RemoteServiceViewHolder( +class CompleteRemoteServiceViewHolder( private val binding: ItemListServicePickerBinding, private val listener: ServicePickerListener, ) : RecyclerView.ViewHolder(binding.root) { @SuppressLint("SetTextI18n") - fun bindCell(item: RemoteServiceDetails) { + fun bindCell(item: CompleteRemoteServiceDetails) { // fixme: had no getString originally? binding.serviceType.text = itemView.context.getString(item.type.nameRes) setDrawableByServiceType(binding.serviceIcon, item.service.type) binding.serviceName.text = item.service.name - binding.serviceAddress.text = "${item.device.address}:${item.service.port}" + binding.serviceAddress.text = item.service.address binding.cvService.setOnClickListener { listener.onServiceClick(item) } } } interface ServicePickerListener { - fun onServiceClick(serviceDetails: RemoteServiceDetails) + fun onServiceClick(service: CompleteRemoteServiceDetails) } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt index f02b8fb9..2dee7170 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt @@ -37,8 +37,7 @@ import coil.load import com.github.livingwithhippos.unchained.R import com.github.livingwithhippos.unchained.base.DeleteDialogFragment import com.github.livingwithhippos.unchained.base.UnchainedFragment -import com.github.livingwithhippos.unchained.data.local.RemoteDevice -import com.github.livingwithhippos.unchained.data.local.RemoteService +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteService import com.github.livingwithhippos.unchained.data.local.RemoteServiceType import com.github.livingwithhippos.unchained.data.local.serviceTypeMap import com.github.livingwithhippos.unchained.data.model.Alternative @@ -71,7 +70,7 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { private val args: DownloadDetailsFragmentArgs by navArgs() - private val deviceServiceMap: MutableMap> = mutableMapOf() + private val servicesList: MutableList = mutableListOf() private var _binding: FragmentDownloadDetailsBinding? = null private val binding get() = _binding!! @@ -294,10 +293,10 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { } lifecycle.coroutineScope.launch { - viewModel.devicesAndServices().collect { + viewModel.allServices().collect { // used to populate the menu - deviceServiceMap.clear() - deviceServiceMap.putAll(it) + servicesList.clear() + servicesList.addAll(it) } } @@ -307,13 +306,11 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { // send media to default device } - is DownloadEvent.DeviceAndServices -> { - // used to populate the menu - deviceServiceMap.clear() - deviceServiceMap.putAll(content.devicesServices) - } - is DownloadEvent.KodiDevices -> {} + is DownloadEvent.AllServices -> { + servicesList.clear() + servicesList.addAll(content.services) + } } } @@ -329,14 +326,10 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { val recentService: Int = viewModel.getRecentService() - val defaultDevice: Map.Entry>? = - deviceServiceMap.firstNotNullOfOrNull { if (it.key.isDefault) it else null } - val defaultService: RemoteService? = defaultDevice?.value?.firstOrNull { it.isDefault } - val servicesNumber = deviceServiceMap.values.sumOf { it.size } - val recentServiceItem: RemoteService? = - deviceServiceMap.firstNotNullOfOrNull { - it.value.firstOrNull { service -> service.id == recentService } - } + val defaultService: CompleteRemoteService? = servicesList.firstOrNull { it.isDefault } + val recentServiceItem: CompleteRemoteService? = servicesList.firstOrNull { service -> + service.id == recentService + } val popup = PopupMenu(requireContext(), v) popup.menuInflater.inflate(R.menu.basic_streaming_popup, popup.menu) @@ -351,7 +344,7 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { getString(R.string.recent_service_format, serviceName) } - if (defaultDevice == null || defaultService == null) { + if (defaultService == null) { popup.menu.findItem(R.id.default_service).isVisible = false } else { val serviceName = getString(serviceTypeMap[defaultService.type]!!.nameRes) @@ -359,7 +352,7 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { getString(R.string.default_service_format, serviceName) } - if (servicesNumber == 0) { + if (servicesList.isEmpty()) { popup.menu.findItem(R.id.pick_service).isVisible = false } @@ -371,31 +364,18 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { // save the new sorting preference when (menuItem.itemId) { R.id.recent_service -> { - val recentDeviceItem = - deviceServiceMap.keys.firstOrNull { it.id == recentServiceItem?.device } - - if (recentServiceItem != null && recentDeviceItem != null) { + if (recentServiceItem != null) { val serviceType: RemoteServiceType = getServiceType(recentServiceItem.type)!! - playOnDeviceService( - url ?: args.details.download, - recentDeviceItem, - recentServiceItem, - serviceType, - ) + playOnService(url ?: args.details.download, recentServiceItem, serviceType) } } R.id.default_service -> { - if (defaultDevice != null && defaultService != null) { + if (defaultService != null) { val serviceType: RemoteServiceType = getServiceType(defaultService.type)!! - playOnDeviceService( - url ?: args.details.download, - defaultDevice.key, - defaultService, - serviceType, - ) + playOnService(url ?: args.details.download, defaultService, serviceType) } } @@ -435,9 +415,7 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { val recentService: Int = viewModel.getRecentService() - val defaultDevice: Map.Entry>? = - deviceServiceMap.firstNotNullOfOrNull { if (it.key.isDefault) it else null } - val defaultService: RemoteService? = defaultDevice?.value?.firstOrNull { it.isDefault } + val defaultService: CompleteRemoteService? = servicesList.firstOrNull { it.isDefault } // get all the services of the corresponding menu item // populate according to results @@ -453,16 +431,11 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { defaultLayout.findViewById(R.id.serviceName).text = defaultService.name // ip from device, port from service defaultLayout.findViewById(R.id.serviceAddress).text = - "${defaultDevice.key.address}:${defaultService.port}" + defaultService.address.trim() defaultLayout.setOnClickListener { if (popup.isShowing) popup.dismiss() - playOnDeviceService( - url ?: args.details.download, - defaultDevice.key, - defaultService, - serviceType, - ) + playOnService(url ?: args.details.download, defaultService, serviceType) } } else { defaultLayout.visibility = View.GONE @@ -474,15 +447,12 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { val recentLayout = popup.contentView.findViewById(R.id.recentServiceLayout) if (recentService != -1 && recentService != defaultService?.id) { - val recentServiceItem: RemoteService? = - deviceServiceMap.firstNotNullOfOrNull { - it.value.firstOrNull { service -> service.id == recentService } - } + val recentServiceItem: CompleteRemoteService? = servicesList.firstOrNull { service -> + service.id == recentService + } if (recentServiceItem != null) { - val recentDeviceItem = - deviceServiceMap.keys.firstOrNull { it.id == recentServiceItem.device } val serviceType: RemoteServiceType? = getServiceType(recentServiceItem.type) - if (recentDeviceItem != null && serviceType != null) { + if (serviceType != null) { recentLayout .findViewById(R.id.recentServiceIcon) .setImageResource(serviceType.iconRes) @@ -490,16 +460,11 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { recentServiceItem.name // ip from device, port from service recentLayout.findViewById(R.id.recentServiceAddress).text = - "${recentDeviceItem.address}:${recentServiceItem.port}" + recentServiceItem.address.trim() recentLayout.setOnClickListener { if (popup.isShowing) popup.dismiss() - playOnDeviceService( - url ?: args.details.download, - recentDeviceItem, - recentServiceItem, - serviceType, - ) + playOnService(url ?: args.details.download, recentServiceItem, serviceType) } } else { recentLayout.visibility = View.GONE @@ -512,19 +477,13 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { } val pickerLayout = popup.contentView.findViewById(R.id.pickServiceLayout) - val servicesNumber = deviceServiceMap.values.sumOf { it.size } + val servicesNumber = servicesList.size pickerLayout.findViewById(R.id.servicesNumber).text = resources.getQuantityString( R.plurals.service_number_format, servicesNumber, servicesNumber, ) - pickerLayout.findViewById(R.id.devicesNumber).text = - resources.getQuantityString( - R.plurals.device_number_format, - deviceServiceMap.keys.size, - deviceServiceMap.keys.size, - ) pickerLayout.setOnClickListener { if (popup.isShowing) popup.dismiss() @@ -551,19 +510,18 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { } } - private fun playOnDeviceService( + private fun playOnService( link: String, - device: RemoteDevice, - service: RemoteService, + service: CompleteRemoteService, serviceType: RemoteServiceType, ) { when (serviceType) { RemoteServiceType.KODI -> { - viewModel.openUrlOnKodi(mediaURL = link, kodiDevice = device, kodiService = service) + viewModel.openUrlOnKodi(mediaURL = link, kodiService = service) } RemoteServiceType.VLC -> { - viewModel.openUrlOnVLC(mediaURL = link, vlcDevice = device, vlcService = service) + viewModel.openUrlOnVLC(mediaURL = link, vlcService = service) } else -> { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/ServicePickerDialog.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/ServicePickerDialog.kt index 8bd67367..1a63684b 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/ServicePickerDialog.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/ServicePickerDialog.kt @@ -6,7 +6,7 @@ import androidx.fragment.app.DialogFragment import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.RecyclerView import com.github.livingwithhippos.unchained.R -import com.github.livingwithhippos.unchained.data.local.RemoteServiceDetails +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteServiceDetails import com.github.livingwithhippos.unchained.data.local.serviceTypeMap import com.github.livingwithhippos.unchained.downloaddetails.model.ServicePickerAdapter import com.github.livingwithhippos.unchained.downloaddetails.model.ServicePickerListener @@ -39,17 +39,14 @@ class ServicePickerDialog : DialogFragment(), ServicePickerListener { viewModel.eventLiveData.observe(this) { event -> when (val content = event.getContentIfNotHandled()) { - is DownloadEvent.DeviceAndServices -> { + is DownloadEvent.AllServices -> { - val devSer: List = - content.devicesServices.flatMap { - it.value.map { serv -> - RemoteServiceDetails( - service = serv, - device = it.key, - type = serviceTypeMap[serv.type]!!, - ) - } + val devSer: List = + content.services.map { serv -> + CompleteRemoteServiceDetails( + service = serv, + type = serviceTypeMap[serv.type]!!, + ) } adapter.submitList(devSer) } @@ -57,7 +54,7 @@ class ServicePickerDialog : DialogFragment(), ServicePickerListener { } } - viewModel.fetchDevicesAndServices() + viewModel.fetchServices() builder.setView(view).setTitle(R.string.services).setNegativeButton( getString(R.string.close) @@ -69,7 +66,7 @@ class ServicePickerDialog : DialogFragment(), ServicePickerListener { } ?: throw IllegalStateException("Activity cannot be null") } - override fun onServiceClick(serviceDetails: RemoteServiceDetails) { + override fun onServiceClick(serviceDetails: CompleteRemoteServiceDetails) { val link = arguments?.getString("downloadUrl") if (link == null) { Timber.e("Download url is null") diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt index 56b2329a..d556f2c1 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt @@ -1,19 +1,19 @@ package com.github.livingwithhippos.unchained.downloaddetails.viewmodel import android.content.SharedPreferences +import androidx.core.content.edit import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.livingwithhippos.unchained.data.local.RemoteDevice -import com.github.livingwithhippos.unchained.data.local.RemoteService -import com.github.livingwithhippos.unchained.data.local.RemoteServiceDetails +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteService +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteServiceDetails import com.github.livingwithhippos.unchained.data.local.RemoteServiceType import com.github.livingwithhippos.unchained.data.model.KodiDevice import com.github.livingwithhippos.unchained.data.model.Stream import com.github.livingwithhippos.unchained.data.repository.DownloadRepository import com.github.livingwithhippos.unchained.data.repository.KodiRepository -import com.github.livingwithhippos.unchained.data.repository.RemoteDeviceRepository import com.github.livingwithhippos.unchained.data.repository.RemoteRepository +import com.github.livingwithhippos.unchained.data.repository.ServiceRepository import com.github.livingwithhippos.unchained.data.repository.StreamingRepository import com.github.livingwithhippos.unchained.utilities.EitherResult import com.github.livingwithhippos.unchained.utilities.Event @@ -34,7 +34,7 @@ constructor( private val downloadRepository: DownloadRepository, private val kodiRepository: KodiRepository, private val remoteServiceRepository: RemoteRepository, - private val remoteDeviceRepository: RemoteDeviceRepository, + private val serviceRepository: ServiceRepository, ) : ViewModel() { val streamLiveData = MutableLiveData() @@ -57,13 +57,12 @@ constructor( } } - fun openUrlOnKodi(mediaURL: String, kodiDevice: RemoteDevice, kodiService: RemoteService) { + fun openUrlOnKodi(mediaURL: String, kodiService: CompleteRemoteService) { viewModelScope.launch { try { val response = kodiRepository.openUrl( - kodiDevice.address, - kodiService.port, + kodiService.address, mediaURL, kodiService.username, kodiService.password, @@ -77,14 +76,13 @@ constructor( } } - fun openUrlOnVLC(mediaURL: String, vlcDevice: RemoteDevice, vlcService: RemoteService) { + fun openUrlOnVLC(mediaURL: String, vlcService: CompleteRemoteService) { viewModelScope.launch { try { val response = remoteServiceRepository.openUrl( - vlcDevice.address, - vlcService.port, + vlcService.address, mediaURL, vlcService.username, vlcService.password, @@ -112,19 +110,24 @@ constructor( return preferences.getString("custom_media_player", "") ?: "" } - fun fetchDevicesAndServices(mediaPlayerOnly: Boolean = true) { - // todo: replace other uses with [devicesAndServices] + fun fetchServices(mediaPlayerOnly: Boolean = true) { + // todo: replace other uses with [allServices] viewModelScope.launch { - val devices: Map> = - if (mediaPlayerOnly) remoteDeviceRepository.getMediaPlayerDevicesAndServices() - else remoteDeviceRepository.getDevicesAndServices() + val services: List = + if (mediaPlayerOnly) { + serviceRepository.getServicesTypes( + types = listOf(RemoteServiceType.KODI.value, RemoteServiceType.VLC.value) + ) + } else serviceRepository.getServices() - eventLiveData.postEvent(DownloadEvent.DeviceAndServices(devices)) + eventLiveData.postEvent(DownloadEvent.AllServices(services)) } } - suspend fun devicesAndServices(): Flow>> { - return remoteDeviceRepository.getMediaPlayerDevicesAndServicesFlow() + suspend fun allServices(): Flow> { + return serviceRepository.getServicesTypesFlow( + types = listOf(RemoteServiceType.KODI.value, RemoteServiceType.VLC.value) + ) } /** @@ -136,20 +139,17 @@ constructor( } private fun setRecentService(serviceId: Int) { - with(preferences.edit()) { - putInt(RECENT_SERVICE_KEY, serviceId).apply() - apply() - } + preferences.edit { putInt(RECENT_SERVICE_KEY, serviceId) } } - fun openOnRemoteService(serviceDetails: RemoteServiceDetails, link: String) { + fun openOnRemoteService(serviceDetails: CompleteRemoteServiceDetails, link: String) { setRecentService(serviceDetails.service.id) when (serviceDetails.service.type) { RemoteServiceType.KODI.value -> { - openUrlOnKodi(link, serviceDetails.device, serviceDetails.service) + openUrlOnKodi(link, serviceDetails.service) } RemoteServiceType.VLC.value -> { - openUrlOnVLC(link, serviceDetails.device, serviceDetails.service) + openUrlOnVLC(link, serviceDetails.service) } else -> { Timber.e("Unknown service type: ${serviceDetails.service.type}") @@ -175,9 +175,7 @@ sealed class DownloadDetailsMessage { sealed class DownloadEvent { data class KodiDevices(val devices: List) : DownloadEvent() - data class DeviceAndServices(val devicesServices: Map>) : - DownloadEvent() + data class AllServices(val services: List) : DownloadEvent() - data class DefaultDeviceService(val device: RemoteDevice, val service: RemoteService) : - DownloadEvent() + data class DefaultDeviceService(val service: CompleteRemoteService) : DownloadEvent() } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/view/FolderListFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/view/FolderListFragment.kt index 56aa11f4..a33e68c5 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/view/FolderListFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/view/FolderListFragment.kt @@ -394,12 +394,16 @@ class FolderListFragment : UnchainedFragment(), DownloadListListener { customizedList.addAll(items.filter { it.fileSize > viewModel.getMinFileSize() }) } if (filterType) { - val temp = customizedList.filter { mediaRegex.find(it.filename) != null } + val temp = customizedList.filter { + mediaRegex.find(it.filename.lowercase()) != null + } customizedList.clear() customizedList.addAll(temp) } if (!filterQuery.isNullOrBlank()) { - val temp = customizedList.filter { item -> item.filename.contains(filterQuery) } + val temp = customizedList.filter { item -> + item.filename.contains(filterQuery, ignoreCase = true) + } customizedList.clear() customizedList.addAll(temp) } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/viewmodel/FolderListViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/viewmodel/FolderListViewModel.kt index 9f1cde70..d2e45395 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/viewmodel/FolderListViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/folderlist/viewmodel/FolderListViewModel.kt @@ -1,6 +1,7 @@ package com.github.livingwithhippos.unchained.folderlist.viewmodel import android.content.SharedPreferences +import androidx.core.content.edit import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -107,11 +108,10 @@ constructor( // simulate debounce queryJob?.cancel() - queryJob = - viewModelScope.launch { - delay(500) - if (isActive) queryLiveData.postValue(query?.trim() ?: "") - } + queryJob = viewModelScope.launch { + delay(500) + if (isActive) queryLiveData.postValue(query?.trim() ?: "") + } } fun getMinFileSize(): Long { @@ -121,10 +121,7 @@ constructor( } fun setFilterSizePreference(enabled: Boolean) { - with(preferences.edit()) { - putBoolean(KEY_LIST_FILTER_SIZE, enabled) - apply() - } + preferences.edit { putBoolean(KEY_LIST_FILTER_SIZE, enabled) } } fun getFilterSizePreference(): Boolean { @@ -132,10 +129,7 @@ constructor( } fun setFilterTypePreference(enabled: Boolean) { - with(preferences.edit()) { - putBoolean(KEY_LIST_FILTER_TYPE, enabled) - apply() - } + preferences.edit { putBoolean(KEY_LIST_FILTER_TYPE, enabled) } } fun getFilterTypePreference(): Boolean { @@ -143,10 +137,7 @@ constructor( } fun setListSortPreference(tag: String) { - with(preferences.edit()) { - putString(KEY_LIST_SORTING, tag) - apply() - } + preferences.edit { putString(KEY_LIST_SORTING, tag) } } fun getListSortPreference(): String { @@ -155,10 +146,7 @@ constructor( } fun setScrollingAllowed(allow: Boolean) { - with(preferences.edit()) { - putBoolean(KEY_ALLOW_SCROLLING, allow) - apply() - } + preferences.edit { putBoolean(KEY_ALLOW_SCROLLING, allow) } } fun getScrollingAllowed(): Boolean { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt index d072099a..6b285d09 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/view/ListsTabFragment.kt @@ -118,11 +118,10 @@ class ListsTabFragment : UnchainedFragment() { // simulate debounce queryJob?.cancel() - queryJob = - lifecycleScope.launch { - delay(500) - if (isActive) viewModel.setListFilter(newText) - } + queryJob = lifecycleScope.launch { + delay(500) + if (isActive) viewModel.setListFilter(newText) + } return true } } @@ -552,6 +551,7 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener { viewModel.downloadItemLiveData.observe( viewLifecycleOwner, EventObserver { links -> + if (_binding == null) return@EventObserver // todo: if it gets emptied null/empty should be processed too if (links.isNotEmpty()) { // simulate list refresh @@ -567,6 +567,7 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener { activityViewModel.listStateLiveData.observe( viewLifecycleOwner, EventObserver { + if (_binding == null) return@EventObserver when (it) { ListState.UpdateDownload -> { lifecycleScope.launch { @@ -808,6 +809,7 @@ class TorrentsListFragment : UnchainedFragment(), TorrentListListener { activityViewModel.listStateLiveData.observe( viewLifecycleOwner, EventObserver { + if (_binding == null) return@EventObserver when (it) { ListState.UpdateTorrent -> { lifecycleScope.launch { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/view/NewDownloadFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/view/NewDownloadFragment.kt index cd44b7c6..315376f0 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/view/NewDownloadFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/view/NewDownloadFragment.kt @@ -356,8 +356,9 @@ class NewDownloadFragment : UnchainedFragment() { return@setOnClickListener } - val multipleLinks: List = - splitLinks.filter { it.isWebUrl() || it.isSimpleWebUrl() } + val multipleLinks: List = splitLinks.filter { + it.isWebUrl() || it.isSimpleWebUrl() + } if (multipleLinks.isEmpty()) { Timber.w("Invalid link: $link") diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/Parser.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/Parser.kt index 0424a94a..5f8583cf 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/Parser.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/Parser.kt @@ -16,6 +16,7 @@ import com.github.livingwithhippos.unchained.utilities.extension.formatStringFor import com.github.livingwithhippos.unchained.utilities.extension.removeWebFormatting import com.github.livingwithhippos.unchained.utilities.parseCommonSize import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -50,140 +51,146 @@ class Parser( fun completeSearch(plugin: Plugin, query: String, category: String? = null, page: Int = 1) = flow { - if (query.isBlank()) emit(ParserResult.MissingQuery) - else { - val currentQuery = formatStringForSearch(query) - if (!plugin.isCompatible()) { - emit(ParserResult.PluginVersionUnsupported) - } else { - val currentCategory = - if (category.isNullOrBlank()) null else getCategory(plugin, category) - - val queryUrl = - replaceData( - oldUrl = - if (currentCategory == null) plugin.search.urlNoCategory - else plugin.search.urlCategory!!, - url = plugin.url, - query = currentQuery, - category = currentCategory, - page = page, - ) + if (query.isBlank()) { + emit(ParserResult.MissingQuery) + return@flow + } - emit(ParserResult.SearchStarted(-1)) - val response = getSourceResult(queryUrl) - when (response) { - is WebResponse.EmptyBodyError -> emit(ParserResult.NetworkBodyError) - is WebResponse.ExceptionError -> emit(ParserResult.SourceError) - is WebResponse.StatusError -> { - if (response.code == 403) { - emit(ParserResult.ScrapeProtectionError(queryUrl)) - } else { - emit(ParserResult.SourceError) - } - } + val currentQuery = formatStringForSearch(query) + if (!plugin.isCompatible()) { + emit(ParserResult.PluginVersionUnsupported) + return@flow + } - is WebResponse.Success -> { - /** Parsing data with the internal link mechanism */ - val source = response.source - when { - plugin.download.internalParser != null -> { - emit(ParserResult.SearchStarted(-1)) - val innerSource = mutableListOf() - - plugin.download.internalParser.link.regexps.forEach { - val linksFound = parseList(it, source, plugin.url) - innerSource.addAll(linksFound) - if ( - plugin.download.internalParser.link.regexUse == "first" - ) { - // if I wanted to get only the first matches I can exit - // the - // loop if I have - // results - if (linksFound.isNotEmpty()) return@forEach - } - } + val currentCategory = + if (category.isNullOrBlank()) null else getCategory(plugin, category) + + val queryUrl = + replaceData( + oldUrl = + if (currentCategory == null) plugin.search.urlNoCategory + else plugin.search.urlCategory!!, + url = plugin.url, + query = currentQuery, + category = currentCategory, + page = page, + ) + emit(ParserResult.SearchStarted(-1)) + + when (val response = getSourceResult(queryUrl)) { + is WebResponse.EmptyBodyError -> emit(ParserResult.NetworkBodyError) + is WebResponse.ExceptionError -> emit(ParserResult.SourceError) + is WebResponse.StatusError -> { + if (response.code == 403) { + emit(ParserResult.ScrapeProtectionError(queryUrl)) + } else { + emit(ParserResult.SourceError) + } + } - emit(ParserResult.SearchStarted(innerSource.size)) - if (innerSource.isNotEmpty()) { - for (link in innerSource) { - // parse every page linked to the results - val s = getSource(link) - if (s.isNotBlank()) { - val scrapedItem = - parseInnerLink( - plugin.download.regexes, - s, - link, - plugin.url, - ) - emit(ParserResult.SingleResult(scrapedItem)) - } else { - emit(ParserResult.SourceError) - } - } - emit(ParserResult.SearchFinished) - } else { - emit(ParserResult.EmptyInnerLinks) - } + is WebResponse.Success -> { + /** Parsing data with the internal link mechanism */ + val source = response.source + when { + plugin.download.internalParser != null -> { + emit(ParserResult.SearchStarted(-1)) + val innerSource = mutableListOf() + // todo: check if one is failing for cloudflare, then skip the + // others + + plugin.download.internalParser.link.regexps.forEach { + val linksFound = parseList(it, source, plugin.url) + innerSource.addAll(linksFound) + if (plugin.download.internalParser.link.regexUse == "first") { + // if I wanted to get only the first matches I can exit + // the loop if I have results + if (linksFound.isNotEmpty()) return@forEach } - plugin.download.directParser != null -> { - emit( - ParserResult.Results( - parseDirect( - plugin.download.directParser, + } + + emit(ParserResult.SearchStarted(innerSource.size)) + if (innerSource.isNotEmpty()) { + for (link in innerSource) { + delay(50) + // parse every page linked to the results + val s = getSource(link) + if (s.isNotBlank()) { + val scrapedItem = + parseInnerLink( plugin.download.regexes, - source, + s, + link, plugin.url, ) - ) - ) - emit(ParserResult.SearchFinished) + emit(ParserResult.SingleResult(scrapedItem)) + } else { + emit(ParserResult.SourceError) + } } - plugin.download.tableLink != null -> { - emit( - ParserResult.Results( - parseTable( - plugin.download.tableLink, - plugin.download.regexes, - source, - plugin.url, - ) - ) + emit(ParserResult.SearchFinished) + } else { + emit(ParserResult.EmptyInnerLinks) + } + } + + plugin.download.directParser != null -> { + emit( + ParserResult.Results( + parseDirect( + plugin.download.directParser, + plugin.download.regexes, + source, + plugin.url, ) - emit(ParserResult.SearchFinished) - } - plugin.download.indirectTableLink != null -> { - emit(ParserResult.SearchStarted(-1)) - val links = - parseIndirectTable( - plugin.download.indirectTableLink, + ) + ) + emit(ParserResult.SearchFinished) + } + + plugin.download.tableLink != null -> { + emit( + ParserResult.Results( + parseTable( + plugin.download.tableLink, + plugin.download.regexes, + source, + plugin.url, + ) + ) + ) + emit(ParserResult.SearchFinished) + } + + plugin.download.indirectTableLink != null -> { + emit(ParserResult.SearchStarted(-1)) + val links = + parseIndirectTable( + plugin.download.indirectTableLink, + plugin.download.regexes, + source, + plugin.url, + ) + emit(ParserResult.SearchStarted(links.size)) + links.forEach { + delay(50) + val itemSource = getSource(it) + if (itemSource.isNotBlank()) { + val scrapedItem = + parseInnerLink( plugin.download.regexes, - source, + itemSource, + it, plugin.url, ) - emit(ParserResult.SearchStarted(links.size)) - links.forEach { - val itemSource = getSource(it) - if (itemSource.isNotBlank()) { - val scrapedItem = - parseInnerLink( - plugin.download.regexes, - itemSource, - it, - plugin.url, - ) - emit(ParserResult.SingleResult(scrapedItem)) - } else { - emit(ParserResult.SourceError) - } - } - emit(ParserResult.SearchFinished) + emit(ParserResult.SingleResult(scrapedItem)) + } else { + emit(ParserResult.SourceError) } - else -> emit(ParserResult.MissingImplementationError) } + emit(ParserResult.SearchFinished) } + + else -> emit(ParserResult.MissingImplementationError) } } } @@ -204,6 +211,7 @@ class Parser( tableParser.idName != null -> doc.getElementById(tableParser.idName) tableParser.className != null -> doc.getElementsByClass(tableParser.className).firstOrNull() + tableParser.index != null -> { // INDEXES STARTS FROM ZERO val tables = doc.getElementsByTag("table") @@ -215,6 +223,7 @@ class Parser( return emptyList() } } + else -> doc.getElementsByTag("table").first() } ?: return emptyList() @@ -374,6 +383,7 @@ class Parser( tableLink.idName != null -> doc.getElementById(tableLink.idName) tableLink.className != null -> doc.getElementsByClass(tableLink.className).firstOrNull() + tableLink.index != null -> { // INDEXES STARTS FROM ZERO val tables = doc.getElementsByTag("table") @@ -385,6 +395,7 @@ class Parser( return emptyList() } } + else -> doc.getElementsByTag("table").first() } ?: return emptyList() @@ -593,11 +604,13 @@ class Parser( if (url.endsWith("/") && match.startsWith("/")) url.removeSuffix("/") + match else url + match } + "append_other" -> { if (customRegex.other!!.endsWith("/") && match.startsWith("/")) customRegex.other.removeSuffix("/") + match else customRegex.other + match } + "complete" -> match else -> match } @@ -623,11 +636,13 @@ class Parser( url.removeSuffix("/") + match else url + match } + "append_other" -> { if (regex.other!!.endsWith("/") && match.startsWith("/")) regex.other.removeSuffix("/") + match else regex.other + match } + "complete" -> match else -> match } @@ -670,11 +685,13 @@ class Parser( url.removeSuffix("/") + result else url + result } + "append_other" -> { if (customRegex.other!!.endsWith("/") && result.startsWith("/")) customRegex.other.removeSuffix("/") + result else customRegex.other + result } + "complete" -> result else -> result } @@ -709,11 +726,13 @@ class Parser( url.removeSuffix("/") + result else url + result } + "append_other" -> { if (customRegex.other!!.endsWith("/") && result.startsWith("/")) customRegex.other.removeSuffix("/") + result else customRegex.other + result } + "complete" -> result else -> result } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/model/Plugin.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/model/Plugin.kt index 18ea0909..29aa2ec3 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/model/Plugin.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/plugins/model/Plugin.kt @@ -19,6 +19,13 @@ data class Plugin( @param:Json(name = "supported_categories") val supportedCategories: SupportedCategories, @param:Json(name = "search") val search: PluginSearch, @param:Json(name = "download") val download: PluginDownload, + /** Selected plugins are the ones enabled for the mass search in the Plugin Search Fragment. */ + @param:Json(name = "selected") var selected: Boolean?, + /** + * Repository is the path or url of the repository, something to distinguish plugins with the + * same name + */ + @param:Json(name = "repository") var repository: String?, ) : Parcelable { fun isCompatible(): Boolean { return engineVersion.toInt() == Parser.PLUGIN_ENGINE_VERSION.toInt() && diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceFragment.kt index 4980e13c..e2cb0dc0 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceFragment.kt @@ -155,9 +155,7 @@ class RemoteDeviceFragment : UnchainedFragment(), ServiceListListener { R.id.new_remote_service -> { val action = RemoteDeviceFragmentDirections - .actionRemoteDeviceFragmentToRemoteServiceFragment( - deviceID = args.item!!.id - ) + .actionRemoteDeviceFragmentToRemoteServiceFragment(device = args.item!!) findNavController().navigate(action) true } @@ -207,7 +205,7 @@ class RemoteDeviceFragment : UnchainedFragment(), ServiceListListener { val action = RemoteDeviceFragmentDirections.actionRemoteDeviceFragmentToRemoteServiceFragment( item = item, - deviceID = args.item!!.id, + device = args.item!!, ) findNavController().navigate(action) } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt index 4cef5ce4..4702a155 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt @@ -40,6 +40,7 @@ class RemoteServiceFragment : Fragment() { _binding = FragmentRemoteServiceBinding.inflate(inflater, container, false) val item: RemoteService? = args.item + val deviceID: Int = args.device.id val serviceTypeView = binding.serviceTypePicker.editText as? AutoCompleteTextView @@ -70,6 +71,7 @@ class RemoteServiceFragment : Fragment() { binding.tiUsername.setText(item.username ?: "") binding.tiPassword.setText(item.password.toString()) binding.switchDefault.isChecked = item.isDefault + binding.tiApiToken.setText(item.apiToken) setupServiceType(binding, item.type, serviceTypeView) } @@ -81,11 +83,34 @@ class RemoteServiceFragment : Fragment() { } } + binding.bTestService.setOnClickListener { + val username = binding.tiUsername.text.toString().trim() + val password = binding.tiPassword.text.toString().trim() + val port = binding.tiPort.text.toString().toIntOrNull() + val apiToken = binding.tiApiToken.text.toString().trim() + val serviceType = getServiceType(binding.servicePickerText.text.toString()) + + if (args.device.address.isBlank() || port == null || serviceType == null) { + context?.showToast(R.string.missing_parameter) + } else { + binding.bTestService.isEnabled = false + viewModel.testService( + serviceType, + args.device.address, + port, + username.ifBlank { null }, + password.ifBlank { null }, + apiToken.ifBlank { null }, + ) + } + } + binding.bSaveService.setOnClickListener { val name = binding.tiName.text.toString().trim() val username = binding.tiUsername.text.toString().trim() val password = binding.tiPassword.text.toString().trim() val port = binding.tiPort.text.toString().toIntOrNull() + val apiToken = binding.tiApiToken.text.toString().trim() val serviceId = item?.id ?: 0 if (name.isBlank() || port == null) { @@ -98,13 +123,13 @@ class RemoteServiceFragment : Fragment() { val remoteService = RemoteService( id = serviceId, - device = args.deviceID, + device = deviceID, name = name, port = port, username = username.ifBlank { null }, password = password.ifBlank { null }, type = serviceType.value, - apiToken = binding.tiApiToken.text.toString().trim(), + apiToken = apiToken, isDefault = false, ) viewModel.updateService(remoteService) @@ -114,7 +139,7 @@ class RemoteServiceFragment : Fragment() { val remoteService = RemoteService( id = serviceId, - device = args.deviceID, + device = deviceID, name = name, port = port, username = username.ifBlank { null }, @@ -129,7 +154,7 @@ class RemoteServiceFragment : Fragment() { val remoteService = RemoteService( id = serviceId, - device = args.deviceID, + device = deviceID, name = name, port = port, username = username.ifBlank { null }, @@ -157,7 +182,7 @@ class RemoteServiceFragment : Fragment() { val action = RemoteServiceFragmentDirections.actionRemoteServiceFragmentSelf( item = it.service, - deviceID = args.deviceID, + device = args.device, ) findNavController().navigate(action) } else { @@ -171,6 +196,16 @@ class RemoteServiceFragment : Fragment() { findNavController().popBackStack() } + is DeviceEvent.ServiceWorking -> { + context?.showToast(R.string.connection_successful) + binding.bTestService.isEnabled = true + } + + is DeviceEvent.ServiceNotWorking -> { + context?.showToast(R.string.connection_error) + binding.bTestService.isEnabled = true + } + else -> {} } } @@ -227,6 +262,7 @@ class RemoteServiceFragment : Fragment() { RemoteServiceType.JACKETT -> { binding.switchDefault.isEnabled = false binding.switchDefault.isChecked = false + binding.tfApiToken.visibility = View.VISIBLE } null -> { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/viewmodel/DeviceViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/viewmodel/DeviceViewModel.kt index 91b0b2a2..8297c235 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/viewmodel/DeviceViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/viewmodel/DeviceViewModel.kt @@ -5,18 +5,86 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.livingwithhippos.unchained.data.local.RemoteDevice import com.github.livingwithhippos.unchained.data.local.RemoteService +import com.github.livingwithhippos.unchained.data.local.RemoteServiceType import com.github.livingwithhippos.unchained.data.repository.RemoteDeviceRepository +import com.github.livingwithhippos.unchained.di.ClassicClient import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient import timber.log.Timber @HiltViewModel -class DeviceViewModel @Inject constructor(private val deviceRepository: RemoteDeviceRepository) : - ViewModel() { +class DeviceViewModel +@Inject +constructor( + private val deviceRepository: RemoteDeviceRepository, + @param:ClassicClient private val client: OkHttpClient, +) : ViewModel() { val deviceLiveData = MutableLiveData() + fun testService( + type: RemoteServiceType, + address: String, + port: Int, + username: String?, + password: String?, + apiToken: String?, + ) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + when (type) { + is RemoteServiceType.JACKETT -> { + val url: StringBuilder = StringBuilder() + if ( + !address.startsWith("http://", ignoreCase = true) && + !address.startsWith("https://", ignoreCase = true) + ) { + if (port == 443) url.append("https://") else url.append("http://") + } + url.append(address) + if (port != 80 && port != 443) url.append(":$port") + url.append("/api/v2.0/indexers/all/results/torznab/api?t=caps") + if (apiToken != null) url.append("&apikey=$apiToken") + val request = okhttp3.Request.Builder().url(url.toString()).build() + try { + val response = client.newCall(request).execute() + if (response.isSuccessful) { + Timber.d(response.body.toString()) + deviceLiveData.postValue(DeviceEvent.ServiceWorking) + } else { + deviceLiveData.postValue( + DeviceEvent.ServiceNotWorking(ServiceErrorType.ResponseError) + ) + } + } catch (e: Exception) { + Timber.e(e, "Error testing the service $url") + deviceLiveData.postValue( + DeviceEvent.ServiceNotWorking(ServiceErrorType.Generic) + ) + } + } + + is RemoteServiceType.KODI -> { + // todo: implement or manage from caller + deviceLiveData.postValue( + DeviceEvent.ServiceNotWorking(ServiceErrorType.InvalidService) + ) + } + + is RemoteServiceType.VLC -> { + deviceLiveData.postValue( + DeviceEvent.ServiceNotWorking(ServiceErrorType.InvalidService) + ) + } + } + } + } + } + fun fetchRemoteDevices() { viewModelScope.launch { deviceLiveData.postValue(DeviceEvent.AllDevices(deviceRepository.getAllDevices())) @@ -100,6 +168,10 @@ class DeviceViewModel @Inject constructor(private val deviceRepository: RemoteDe } sealed class DeviceEvent { + data object ServiceWorking : DeviceEvent() + + data class ServiceNotWorking(val errorType: ServiceErrorType) : DeviceEvent() + data object DeletedAll : DeviceEvent() data object DeletedDevice : DeviceEvent() @@ -119,3 +191,11 @@ sealed class DeviceEvent { data class DeletedService(val service: RemoteService) : DeviceEvent() } + +sealed class ServiceErrorType { + data object ResponseError : ServiceErrorType() + + data object InvalidService : ServiceErrorType() + + data object Generic : ServiceErrorType() +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceFragment.kt new file mode 100644 index 00000000..2b7c9b14 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceFragment.kt @@ -0,0 +1,283 @@ +package com.github.livingwithhippos.unchained.remoteservice.view + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.github.livingwithhippos.unchained.R +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteService +import com.github.livingwithhippos.unchained.data.local.RemoteServiceType +import com.github.livingwithhippos.unchained.data.local.serviceTypeMap +import com.github.livingwithhippos.unchained.databinding.FragmentCompleteServiceBinding +import com.github.livingwithhippos.unchained.remoteservice.viewmodel.ServiceEvent +import com.github.livingwithhippos.unchained.remoteservice.viewmodel.ServiceViewModel +import com.github.livingwithhippos.unchained.utilities.extension.showToast +import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber + +@AndroidEntryPoint +class CompleteServiceFragment : Fragment() { + + private val args: CompleteServiceFragmentArgs by navArgs() + + private val viewModel: ServiceViewModel by viewModels() + + private var _binding: FragmentCompleteServiceBinding? = null + private val binding + get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentCompleteServiceBinding.inflate(inflater, container, false) + + val item: CompleteRemoteService? = args.item + + val serviceTypeView = binding.serviceTypePicker.editText as? AutoCompleteTextView + + if (serviceTypeView == null) { + // should not happen, used just to avoid successive checks + Timber.e("serviceTypeView is null") + context?.showToast(R.string.error) + return binding.root + } + + val serviceTypeAdapter = + ArrayAdapter( + requireContext(), + R.layout.basic_dropdown_list_item, + resources.getStringArray(R.array.service_types), + ) + serviceTypeView.setAdapter(serviceTypeAdapter) + + if (item == null) { + // new service, default to kodi + setupServiceType(binding, RemoteServiceType.KODI.value, serviceTypeView) + } else { + // edit service + binding.bSaveService.text = getString(R.string.update) + + binding.tiName.setText(item.name) + binding.tiAddress.setText(item.address.trim()) + binding.tiUsername.setText(item.username ?: "") + binding.tiPassword.setText(item.password ?: "") + binding.switchDefault.isChecked = item.isDefault + binding.tiApiToken.setText(item.apiToken) + + setupServiceType(binding, item.type, serviceTypeView) + } + + serviceTypeView.setOnItemClickListener { _, _, position, _ -> + val selectedService = getServiceType(serviceTypeAdapter.getItem(position)) + if (selectedService != null) { + setupServiceType(binding, selectedService.value) + } + } + + binding.bTestService.setOnClickListener { + val username = binding.tiUsername.text.toString().trim() + val password = binding.tiPassword.text.toString().trim() + val address = binding.tiAddress.text.toString().trim() + val apiToken = binding.tiApiToken.text.toString().trim() + val serviceType = getServiceType(binding.servicePickerText.text.toString()) + + if (address.isBlank() || serviceType == null) { + context?.showToast(R.string.missing_parameter) + } else { + binding.bTestService.isEnabled = false + viewModel.testService( + serviceType, + address, + username.ifBlank { null }, + password.ifBlank { null }, + apiToken.ifBlank { null }, + ) + } + } + + binding.bSaveService.setOnClickListener { + val name = binding.tiName.text.toString().trim() + val username = binding.tiUsername.text.toString().trim() + val password = binding.tiPassword.text.toString().trim() + val address = binding.tiAddress.text.toString().trim() + val apiToken = binding.tiApiToken.text.toString().trim() + val serviceId = item?.id ?: 0 + + if (name.isBlank() || address.isBlank()) { + context?.showToast(R.string.missing_parameter) + return@setOnClickListener + } + + when (val serviceType = getServiceType(binding.servicePickerText.text.toString())) { + RemoteServiceType.JACKETT -> { + val remoteService = + CompleteRemoteService( + id = serviceId, + name = name, + address = address, + username = username.ifBlank { null }, + password = password.ifBlank { null }, + type = serviceType.value, + apiToken = apiToken, + isDefault = false, + ) + viewModel.updateService(remoteService) + } + + RemoteServiceType.KODI -> { + val remoteService = + CompleteRemoteService( + id = serviceId, + name = name, + address = address, + username = username.ifBlank { null }, + password = password.ifBlank { null }, + type = serviceType.value, + isDefault = binding.switchDefault.isChecked, + ) + viewModel.updateService(remoteService) + } + + RemoteServiceType.VLC -> { + val remoteService = + CompleteRemoteService( + id = serviceId, + name = name, + address = address, + username = username.ifBlank { null }, + password = password.ifBlank { null }, + type = serviceType.value, + isDefault = binding.switchDefault.isChecked, + ) + viewModel.updateService(remoteService) + } + + null -> { + Timber.e("Unknown service type saving ${binding.servicePickerText.text}") + } + } + } + + binding.bDeleteService.setOnClickListener { + item?.let { rs -> viewModel.deleteService(rs) } + } + + viewModel.serviceLiveData.observe(viewLifecycleOwner) { + when (it) { + is ServiceEvent.AllServices -> { + // not happening here + } + ServiceEvent.DeletedAll -> { + // not happening here + } + is ServiceEvent.DeletedService -> { + context?.showToast(R.string.service_deleted) + // go back + findNavController().popBackStack() + } + is ServiceEvent.Service -> { + if (args.item == null) { + val action = + CompleteServiceFragmentDirections.actionCompleteServiceFragmentSelf( + item = it.service + ) + findNavController().navigate(action) + /** + * context?.showToast(R.string.service_added) + * // todo: reload the page with the set service + * findNavController().popBackStack() + */ + } else { + context?.showToast(R.string.updated) + } + } + is ServiceEvent.ServiceNotWorking -> { + + context?.showToast(R.string.connection_error) + binding.bTestService.isEnabled = true + } + ServiceEvent.ServiceWorking -> { + context?.showToast(R.string.connection_successful) + binding.bTestService.isEnabled = true + } + } + } + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun getServiceType(text: String?): RemoteServiceType? { + return when (text) { + getString(R.string.kodi) -> { + RemoteServiceType.KODI + } + + getString(R.string.player_vlc) -> { + RemoteServiceType.VLC + } + + getString(R.string.jackett) -> { + RemoteServiceType.JACKETT + } + + else -> { + null + } + } + } + + private fun setupServiceType( + binding: FragmentCompleteServiceBinding, + type: Int, + serviceDropdown: AutoCompleteTextView? = null, + ) { + // note: it gives no ui feedback + binding.bDeleteService.isEnabled = args.item != null + val serviceType = serviceTypeMap[type] + // set up default switch, enable the button only for services that reproduce media + when (serviceType) { + RemoteServiceType.KODI -> { + binding.switchDefault.isEnabled = true + binding.tfApiToken.visibility = View.GONE + } + + RemoteServiceType.VLC -> { + binding.switchDefault.isEnabled = true + binding.tfApiToken.visibility = View.GONE + } + + RemoteServiceType.JACKETT -> { + binding.switchDefault.isEnabled = false + binding.switchDefault.isChecked = false + binding.tfApiToken.visibility = View.VISIBLE + } + + null -> { + Timber.e("Unknown service type $type") + return + } + } + + if (serviceType.isMediaPlayer) { + binding.switchDefault.isEnabled = true + } else { + binding.switchDefault.isEnabled = false + binding.switchDefault.isChecked = false + } + // set the text only if the view has been passed (when starting the fragment) + serviceDropdown?.setText(getString(serviceType.nameRes), false) + } +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceListAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceListAdapter.kt new file mode 100644 index 00000000..bdc67ee7 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceListAdapter.kt @@ -0,0 +1,109 @@ +package com.github.livingwithhippos.unchained.remoteservice.view + +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.selection.ItemDetailsLookup +import androidx.recyclerview.selection.ItemKeyProvider +import androidx.recyclerview.selection.SelectionTracker +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.github.livingwithhippos.unchained.R +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteService +import com.github.livingwithhippos.unchained.databinding.ItemCompleteServiceBinding +import com.github.livingwithhippos.unchained.utilities.extension.setDrawableByServiceType + +class CompleteServiceListAdapter(private val listener: CompleteServiceListListener) : + ListAdapter(DiffCallback()) { + + var tracker: SelectionTracker? = null + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: CompleteRemoteService, + newItem: CompleteRemoteService, + ): Boolean = oldItem.id == newItem.id + + override fun areContentsTheSame( + oldItem: CompleteRemoteService, + newItem: CompleteRemoteService, + ): Boolean = + oldItem.isDefault == newItem.isDefault && + oldItem.name == newItem.name && + oldItem.type == newItem.type && + oldItem.address == newItem.address + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): CompleteRemoteServiceViewHolder { + val binding = + ItemCompleteServiceBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return CompleteRemoteServiceViewHolder(binding, listener) + } + + override fun onBindViewHolder(holder: CompleteRemoteServiceViewHolder, position: Int) { + val item = getItem(position) + if (item != null) { + holder.bindCell(item) + } + } + + override fun getItemViewType(position: Int) = R.layout.item_complete_service + + fun getService(position: Int): CompleteRemoteService? = super.getItem(position) + + fun getPosition(id: Int) = currentList.indexOfFirst { it.id == id } +} + +class CompleteRemoteServiceViewHolder( + private val binding: ItemCompleteServiceBinding, + private val listener: CompleteServiceListListener, +) : RecyclerView.ViewHolder(binding.root) { + + var mItem: CompleteRemoteService? = null + + fun bindCell(item: CompleteRemoteService) { + mItem = item + binding.defaultIndicator.visibility = if (item.isDefault) View.VISIBLE else View.INVISIBLE + + binding.tvName.text = item.name + binding.tvAddress.text = item.address.trim() + setDrawableByServiceType(binding.ivType, item.type) + + binding.cvService.setOnClickListener { listener.onServiceClick(item) } + } + + fun getItemDetails(): ItemDetailsLookup.ItemDetails = + object : ItemDetailsLookup.ItemDetails() { + override fun getPosition(): Int = layoutPosition + + override fun getSelectionKey(): CompleteRemoteService? = mItem + } +} + +class CompleteServiceDetailsLookup(private val recyclerView: RecyclerView) : + ItemDetailsLookup() { + override fun getItemDetails(event: MotionEvent): ItemDetails? { + val view = recyclerView.findChildViewUnder(event.x, event.y) + if (view != null) { + return (recyclerView.getChildViewHolder(view) as CompleteRemoteServiceViewHolder) + .getItemDetails() + } + return null + } +} + +class CompleteServiceKeyProvider(private val adapter: CompleteServiceListAdapter) : + ItemKeyProvider(SCOPE_MAPPED) { + override fun getKey(position: Int): CompleteRemoteService? = adapter.getService(position) + + override fun getPosition(key: CompleteRemoteService): Int = adapter.getPosition(key.id) +} + +interface CompleteServiceListListener { + fun onServiceClick(item: CompleteRemoteService) +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceListFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceListFragment.kt new file mode 100644 index 00000000..47e25a3b --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceListFragment.kt @@ -0,0 +1,148 @@ +package com.github.livingwithhippos.unchained.remoteservice.view + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.annotation.MenuRes +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.selection.SelectionPredicates +import androidx.recyclerview.selection.SelectionTracker +import androidx.recyclerview.selection.StorageStrategy +import com.github.livingwithhippos.unchained.R +import com.github.livingwithhippos.unchained.base.UnchainedFragment +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteService +import com.github.livingwithhippos.unchained.databinding.FragmentServicesListBinding +import com.github.livingwithhippos.unchained.remoteservice.viewmodel.ServiceEvent +import com.github.livingwithhippos.unchained.remoteservice.viewmodel.ServiceViewModel +import com.github.livingwithhippos.unchained.utilities.extension.showToast +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class CompleteServiceListFragment : UnchainedFragment(), CompleteServiceListListener { + + private val viewModel: ServiceViewModel by viewModels() + private var _binding: FragmentServicesListBinding? = null + private val binding + get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentServicesListBinding.inflate(inflater, container, false) + + val serviceAdapter = CompleteServiceListAdapter(this) + binding.rvServiceList.adapter = serviceAdapter + + val serviceTracker: SelectionTracker = + SelectionTracker.Builder( + "serviceListSelection", + binding.rvServiceList, + CompleteServiceKeyProvider(serviceAdapter), + CompleteServiceDetailsLookup(binding.rvServiceList), + StorageStrategy.createParcelableStorage(CompleteRemoteService::class.java), + ) + .withSelectionPredicate(SelectionPredicates.createSelectAnything()) + .build() + + serviceAdapter.tracker = serviceTracker + + viewModel.serviceLiveData.observe(viewLifecycleOwner) { + when (it) { + is ServiceEvent.AllServices -> { + serviceAdapter.submitList(it.items) + } + ServiceEvent.DeletedAll -> { + context?.showToast(R.string.service_deleted) + } + is ServiceEvent.DeletedService -> { + // not happening here + context?.showToast(R.string.service_deleted) + } + is ServiceEvent.Service -> { + // not happening here + } + is ServiceEvent.ServiceNotWorking -> { + // not happening here + } + ServiceEvent.ServiceWorking -> { + // not happening here + } + } + } + + binding.bAddService.setOnClickListener { + val action = + CompleteServiceListFragmentDirections + .actionCompleteServiceListFragmentToCompleteServiceFragment() + findNavController().navigate(action) + } + + viewModel.fetchAllServices() + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun showMenu(v: View, @MenuRes menuRes: Int) { + val popup = PopupMenu(requireContext(), v) + popup.menuInflater.inflate(menuRes, popup.menu) + + popup.setOnMenuItemClickListener { menuItem: MenuItem -> + // Respond to menu item click. + when (menuItem.itemId) { + R.id.new_remote_service -> { + val action = + CompleteServiceListFragmentDirections + .actionCompleteServiceListFragmentToCompleteServiceFragment() + findNavController().navigate(action) + true + } + + R.id.delete_all_services -> { + + showDeleteServicesConfirmationDialog() + true + } + + else -> { + false + } + } + } + + popup.setOnDismissListener { + // Respond to popup being dismissed. + } + // Show the popup menu. + popup.show() + } + + private fun showDeleteServicesConfirmationDialog() { + val builder: AlertDialog.Builder? = activity?.let { AlertDialog.Builder(it) } + builder + ?.setMessage(R.string.dialog_confirm_action) + ?.setTitle(R.string.delete_all) + ?.setPositiveButton(R.string.yes) { _, _ -> viewModel.deleteAllServices() } + ?.setNegativeButton(R.string.no) { dialog, _ -> dialog.cancel() } + val dialog: AlertDialog? = builder?.create() + dialog?.show() + } + + override fun onServiceClick(item: CompleteRemoteService) { + val action = + CompleteServiceListFragmentDirections + .actionCompleteServiceListFragmentToCompleteServiceFragment(item = item) + findNavController().navigate(action) + } +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/viewmodel/ServiceViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/viewmodel/ServiceViewModel.kt new file mode 100644 index 00000000..6efc69ae --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/viewmodel/ServiceViewModel.kt @@ -0,0 +1,138 @@ +package com.github.livingwithhippos.unchained.remoteservice.viewmodel + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteService +import com.github.livingwithhippos.unchained.data.local.RemoteServiceType +import com.github.livingwithhippos.unchained.data.repository.ServiceRepository +import com.github.livingwithhippos.unchained.di.ClassicClient +import com.github.livingwithhippos.unchained.remotedevice.viewmodel.ServiceErrorType +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import timber.log.Timber + +@HiltViewModel +class ServiceViewModel +@Inject +constructor( + private val serviceRepository: ServiceRepository, + @param:ClassicClient private val client: OkHttpClient, +) : ViewModel() { + + val serviceLiveData = MutableLiveData() + + fun testService( + type: RemoteServiceType, + address: String, + username: String?, + password: String?, + apiToken: String?, + ) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + when (type) { + is RemoteServiceType.JACKETT -> { + val url: StringBuilder = StringBuilder() + // todo: check if ip and add http + /* + if ( + !address.startsWith("http://", ignoreCase = true) && + !address.startsWith("https://", ignoreCase = true) + ) { + if (port == 443) url.append("https://") else url.append("http://") + } + */ + url.append(address) + url.append("/api/v2.0/indexers/all/results/torznab/api?t=caps") + if (apiToken != null) url.append("&apikey=$apiToken") + val request = okhttp3.Request.Builder().url(url.toString()).build() + try { + val response = client.newCall(request).execute() + if (response.isSuccessful) { + Timber.d(response.body.toString()) + serviceLiveData.postValue(ServiceEvent.ServiceWorking) + } else { + serviceLiveData.postValue( + ServiceEvent.ServiceNotWorking(ServiceErrorType.ResponseError) + ) + } + } catch (e: Exception) { + Timber.e(e, "Error testing the service $url") + serviceLiveData.postValue( + ServiceEvent.ServiceNotWorking(ServiceErrorType.Generic) + ) + } + } + + is RemoteServiceType.KODI -> { + // todo: implement or manage from caller + serviceLiveData.postValue( + ServiceEvent.ServiceNotWorking(ServiceErrorType.InvalidService) + ) + } + + is RemoteServiceType.VLC -> { + serviceLiveData.postValue( + ServiceEvent.ServiceNotWorking(ServiceErrorType.InvalidService) + ) + } + } + } + } + } + + fun fetchAllServices() { + viewModelScope.launch { + serviceLiveData.postValue(ServiceEvent.AllServices(serviceRepository.getServices())) + } + } + + fun updateService(service: CompleteRemoteService) { + viewModelScope.launch { + val insertedRow = serviceRepository.upsertService(service) + val serviceID = serviceRepository.getServiceIDByRow(insertedRow) + // if the default service is updated, remove the old preference + if (serviceID != null) { + if (service.isDefault) { + // fixme: not resetting previous defaults + serviceRepository.setDefaultService(serviceID) + } + val newService = serviceRepository.getService(serviceID) + if (newService != null) serviceLiveData.postValue(ServiceEvent.Service(newService)) + } + } + } + + fun deleteService(service: CompleteRemoteService) { + viewModelScope.launch { + serviceRepository.deleteService(service) + serviceLiveData.postValue(ServiceEvent.DeletedService(service)) + } + } + + fun deleteAllServices() { + viewModelScope.launch { + serviceRepository.deleteAll() + serviceLiveData.postValue(ServiceEvent.DeletedAll) + } + } +} + +sealed class ServiceEvent { + data object ServiceWorking : ServiceEvent() + + data class ServiceNotWorking(val errorType: ServiceErrorType) : ServiceEvent() + + data object DeletedAll : ServiceEvent() + + data class AllServices(val items: List) : ServiceEvent() + + data class Service(val service: CompleteRemoteService) : ServiceEvent() + + data class DeletedService(val service: CompleteRemoteService) : ServiceEvent() +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/model/JsonPluginRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/model/JsonPluginRepository.kt index f58b86d6..39266b6b 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/model/JsonPluginRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/model/JsonPluginRepository.kt @@ -15,6 +15,7 @@ data class JsonPluginRepository( @JsonClass(generateAdapter = true) data class JsonPlugin( @param:Json(name = "id") val id: String, + @param:Json(name = "disabled") val disabled: Boolean?, @param:Json(name = "versions") val versions: List, ) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/model/PluginRepositoryAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/model/PluginRepositoryAdapter.kt index ab990282..821d06f0 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/model/PluginRepositoryAdapter.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/model/PluginRepositoryAdapter.kt @@ -50,7 +50,8 @@ class PluginRepositoryAdapter(private val listener: PluginListener) : ) { return oldItem.name == newItem.name && oldItem.version == newItem.version && - oldItem.status == newItem.status + oldItem.status == newItem.status && + oldItem.disabled == newItem.disabled } return false } @@ -106,7 +107,7 @@ class PluginViewHolder( fun bindCell(item: RepositoryListItem.Plugin) { - setDrawableByPluginStatus(binding.ivStatus, item.status) + setDrawableByPluginStatus(binding.ivStatus, item.status, item.disabled ?: false) binding.tvName.text = item.name if (item.status == PluginStatus.isNew) binding.bDownload.visibility = View.VISIBLE @@ -128,6 +129,20 @@ class PluginViewHolder( binding.tvVersion.text = item.version.toString() binding.tvStatus.text = item.statusTranslation + + if (item.disabled == true) { + binding.bDownload.isEnabled = false + binding.bUpdate.isEnabled = false + binding.tvName.alpha = 0.5f + binding.tvVersion.alpha = 0.5f + binding.tvStatus.alpha = 0.5f + } else { + binding.bDownload.isEnabled = true + binding.bUpdate.isEnabled = true + binding.tvName.alpha = 1f + binding.tvVersion.alpha = 1f + binding.tvStatus.alpha = 1f + } } } @@ -165,6 +180,7 @@ sealed class RepositoryListItem { // see PluginStatus var status: String, var statusTranslation: String, + var disabled: Boolean? = false, ) : RepositoryListItem() } @@ -186,6 +202,9 @@ object PluginStatus { // new. not installable const val incompatible = "incompatible" + + // disabled when it has issues, marked by the repo (website down etc.) + const val disabled = "disabled" } interface PluginListener { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/RepositoryFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/RepositoryFragment.kt index 942db846..30624772 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/RepositoryFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/RepositoryFragment.kt @@ -192,6 +192,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { version = 0f, engine = 0.0f, link = repository.key.link, + disabled = plug.value.firstOrNull()?.disabled ?: false, ) } else { val latestCompatibleVersion: PluginVersion? = @@ -241,6 +242,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { version = 0f, engine = 0.0f, link = repository.key.link, + disabled = false, ) } else { Timber.w( @@ -252,6 +254,8 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { version = installedPlugin.version, engine = 0.0f, link = repository.key.link, + disabled = + onlinePlugin.value.firstOrNull()?.disabled ?: false, ) } @@ -319,40 +323,40 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { } ) } - // add fake repo for plugins installed manually without going through a web repository - // otherwise users won't be able to uninstall them - if (installedData.pluginsData[MANUAL_PLUGINS_REPOSITORY_NAME].isNullOrEmpty().not()) { - plugins.add( - RepositoryListItem.Repository( - link = MANUAL_PLUGINS_REPOSITORY_NAME, - name = getString(R.string.manually_installed_plugins), - version = 1.0, - description = getString(R.string.manually_installed_plugins_description), - author = getString(R.string.various), - ) - ) - - plugins.addAll( - installedData.pluginsData.getValue(MANUAL_PLUGINS_REPOSITORY_NAME).map { - // all are installed, cannot check updates, check only compatibility - val currentStatus = - if (it.isCompatible()) PluginStatus.updated else PluginStatus.unknown - RepositoryListItem.Plugin( - repository = MANUAL_PLUGINS_REPOSITORY_NAME, - name = it.name, - version = it.version, - link = MANUAL_PLUGINS_REPOSITORY_NAME, - author = it.author, - status = currentStatus, - statusTranslation = getStatusTranslation(currentStatus), - ) - } + } + // add fake repo for plugins installed manually without going through a web repository + // otherwise users won't be able to uninstall them + if (installedData.pluginsData[MANUAL_PLUGINS_REPOSITORY_NAME].isNullOrEmpty().not()) { + plugins.add( + RepositoryListItem.Repository( + link = MANUAL_PLUGINS_REPOSITORY_NAME, + name = getString(R.string.manually_installed_plugins), + version = 1.0, + description = getString(R.string.manually_installed_plugins_description), + author = getString(R.string.various), ) - } + ) - adapter.submitList(plugins) - adapter.notifyDataSetChanged() + plugins.addAll( + installedData.pluginsData.getValue(MANUAL_PLUGINS_REPOSITORY_NAME).map { + // all are installed, cannot check updates, check only compatibility + val currentStatus = + if (it.isCompatible()) PluginStatus.updated else PluginStatus.unknown + RepositoryListItem.Plugin( + repository = MANUAL_PLUGINS_REPOSITORY_NAME, + name = it.name, + version = it.version, + link = MANUAL_PLUGINS_REPOSITORY_NAME, + author = it.author, + status = currentStatus, + statusTranslation = getStatusTranslation(currentStatus), + ) + } + ) } + + adapter.submitList(plugins) + adapter.notifyDataSetChanged() } private fun getPluginItemFromVersion( @@ -368,6 +372,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { author = author, status = pluginStatus, statusTranslation = getStatusTranslation(pluginStatus), + disabled = pluginVersion.disabled, ) } @@ -379,6 +384,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { PluginStatus.hasIncompatibleUpdate -> getString(R.string.incompatible_update) PluginStatus.incompatible -> getString(R.string.incompatible) PluginStatus.unknown -> getString(R.string.unknown_status) + PluginStatus.disabled -> getString(R.string.disabled) else -> getString(R.string.unknown_status) } } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/viewmodel/RepositoryViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/viewmodel/RepositoryViewModel.kt index 498f4aaf..1cfab82b 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/viewmodel/RepositoryViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/viewmodel/RepositoryViewModel.kt @@ -128,11 +128,19 @@ constructor( } } - private suspend fun installMultiplePlugins(context: Context, plugins: List) { + private suspend fun installMultiplePlugins( + context: Context, + plugins: List, + skipDisabled: Boolean = true, + ) { var errors = 0 val installResults = mutableListOf() // install all for (plugin in plugins) { + if (skipDisabled && plugin.disabled) { + Timber.d("Skipping disabled plugin ${plugin.plugin}") + continue + } when (val result = downloadRepository.downloadPlugin(plugin.link)) { is EitherResult.Failure -> { Timber.e("Error downloading plugin at ${plugin.link}:\n${result.failure}") @@ -162,15 +170,15 @@ constructor( if (installedPlugins.isNullOrEmpty()) { installMultiplePlugins(context, emptyList()) } else { - val updatablePlugins = - remotePlugins.filter { remotePlugin -> - val installedVersion: Plugin? = - installedPlugins.firstOrNull { it.name == remotePlugin.plugin } - - if (installedVersion == null) false - else installedVersion.version < remotePlugin.version + val updatablePlugins = remotePlugins.filter { remotePlugin -> + val installedVersion: Plugin? = installedPlugins.firstOrNull { + it.name.equals(remotePlugin.plugin, ignoreCase = true) } + if (installedVersion == null) false + else installedVersion.version < remotePlugin.version + } + installMultiplePlugins(context, updatablePlugins) } } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/PluginSearchFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/PluginSearchFragment.kt index 6183d13c..ffd8d38a 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/PluginSearchFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/PluginSearchFragment.kt @@ -7,27 +7,33 @@ import android.view.ViewGroup import android.widget.AutoCompleteTextView import android.widget.Button import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController import com.github.livingwithhippos.unchained.R import com.github.livingwithhippos.unchained.base.UnchainedFragment import com.github.livingwithhippos.unchained.databinding.FragmentSearchPluginsTabBinding import com.github.livingwithhippos.unchained.folderlist.view.FolderListFragment import com.github.livingwithhippos.unchained.plugins.ParserResult -import com.github.livingwithhippos.unchained.plugins.model.Plugin +import com.github.livingwithhippos.unchained.plugins.model.ScrapedItem +import com.github.livingwithhippos.unchained.search.model.SearchItemAdapter +import com.github.livingwithhippos.unchained.search.model.SearchItemListener +import com.github.livingwithhippos.unchained.search.view.SearchFragment.Companion.digitRegex +import com.github.livingwithhippos.unchained.search.viewmodel.PluginsAndServices import com.github.livingwithhippos.unchained.search.viewmodel.SearchViewModel +import com.github.livingwithhippos.unchained.utilities.extension.getThemeColor import com.github.livingwithhippos.unchained.utilities.extension.hideKeyboard import com.github.livingwithhippos.unchained.utilities.extension.showToast import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipGroup import com.google.android.material.sidesheet.SideSheetDialog import dagger.hilt.android.AndroidEntryPoint +import kotlin.time.Instant import timber.log.Timber @AndroidEntryPoint -class PluginSearchFragment : UnchainedFragment() { +class PluginSearchFragment : UnchainedFragment(), SearchItemListener { private val viewModel: SearchViewModel by viewModels() - - private val pluginsList: MutableList = mutableListOf() + private val searchResultsList: MutableList = mutableListOf() private var _binding: FragmentSearchPluginsTabBinding? = null private val binding @@ -43,9 +49,7 @@ class PluginSearchFragment : UnchainedFragment() { setup(binding) viewModel.pluginLiveData.observe(viewLifecycleOwner) { parsedPlugins -> - if (pluginsList.isNotEmpty()) pluginsList.clear() - pluginsList.addAll(parsedPlugins.first) - setupAndShowSheet(inflater, parsedPlugins.first) + setupAndShowSheet(inflater, parsedPlugins) } return binding.root @@ -56,10 +60,19 @@ class PluginSearchFragment : UnchainedFragment() { _binding = null } - private fun setupAndShowSheet(inflater: LayoutInflater, plugins: List) { + private fun setupAndShowSheet( + inflater: LayoutInflater, + pluginsAndServices: PluginsAndServices, + ) { val sideSheetDialog = SideSheetDialog(requireContext()) sideSheetDialog.setContentView(R.layout.sidesheet_search_plugins_options) + sideSheetDialog.findViewById