diff --git a/README.md b/README.md index e0c724e18..446e00838 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Unchained for Android -[![License](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![API](https://img.shields.io/badge/API-22%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=22) [![Build Status](https://img.shields.io/github/workflow/status/LivingWithHippos/unchained-android/build.yaml)](https://github.com/LivingWithHippos/unchained-android/actions) [![Play Store](https://img.shields.io/badge/play%20store-available-brightgreen)](https://play.google.com/store/apps/details?id=com.github.livingwithhippos.unchained) [![F Droid](https://img.shields.io/f-droid/v/com.github.livingwithhippos.unchained)](https://f-droid.org/packages/com.github.livingwithhippos.unchained/) [![translated](https://localization.professiona.li/widgets/unchained-for-android/-/strings/svg-badge.svg)](https://localization.professiona.li/engage/unchained-for-android/) +[![License](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![API](https://img.shields.io/badge/API-22%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=22) [![Build Status](https://img.shields.io/github/actions/workflow/status/LivingWithHippos/unchained-android/build.yaml?branch=master)](https://github.com/LivingWithHippos/unchained-android/actions) [![Play Store](https://img.shields.io/badge/play%20store-available-brightgreen)](https://play.google.com/store/apps/details?id=com.github.livingwithhippos.unchained) [![F Droid](https://img.shields.io/f-droid/v/com.github.livingwithhippos.unchained)](https://f-droid.org/packages/com.github.livingwithhippos.unchained/) Get it on F Droid Get it on Google Play @@ -53,10 +53,6 @@ You have multiple options to install Unchained for Android: ### Developing and Contributing :writing_hand: -## [![Repography logo](https://images.repography.com/logo.svg)](https://repography.com) / -[![Issue status graph](https://images.repography.com/28505435/LivingWithHippos/unchained-android/recent-activity/9be46c12746e55ef26535ea523c2bda5_issues.svg)](https://github.com/LivingWithHippos/unchained-android/issues) - - Contributions are welcome. You can use the [discussion tab](https://github.com/LivingWithHippos/unchained-android/discussions) to ask for help setting up the project. At the moment at least Android Studio 2021.1.1 is needed to build the project. The dev branch is the one where the development happens, it gets merged into master when a release is ready. @@ -67,19 +63,21 @@ This app is written in Kotlin and uses the following architectures/patterns/libr MVVM architectural pattern, Dagger-Hilt for dependency injection, Data Binding for managing ui-data relations, Navigation, Moshi, Retrofit, OkHTTP, Room, Coroutines, Flow, Livedata, Coil -The app is available in English, Italian and French, you can contribute to those or add a new language [here](https://localization.professiona.li/engage/unchained-for-android/) (much appreciated!) +The app is available in English, Italian, Spanish and French, ~~you can contribute to those or add a new language [here](https://localization.professiona.li/engage/unchained-for-android/) (much appreciated!)~~ (the service is currently down, you can still contribute by forking the project and adding the strings to the `strings.xml` file in the `values-xx` folders) + +#### Search Plugins -#### Plugins +[Notice: plugins have been moved to another repository](https://gitlab.com/LivingWithHippos/unchained-plugins) -Check out `PLUGINS.md`. There's also a work in progress [wiki page](https://github.com/LivingWithHippos/unchained-android/wiki/Search-Engine) for creating search plugins. +It's possible to create new plugins with a bit of knowledge of html and regexes. There's also a work in progress [wiki page](https://github.com/LivingWithHippos/unchained-android/wiki/Search-Engine), hopefully there will be more documentation including a video in the future. ### Donate :coffee: -You can use [my referral link](http://real-debrid.com/?id=78841) to get Real Debrid premium. +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/E1E412NFX7) -Offer me coffee or a beer with Liberapay (set renewal to manual to avoid recurring donation) +You can use [my referral link](http://real-debrid.com/?id=78841) to get Real Debrid premium. -Send a tip with [Brave.](https://brave.com/liv466) +Offer me coffee or a beer with [Ko-Fi](https://ko-fi.com/livingwithhippos), [Liberapay](https://liberapay.com/LivingWithHippos/donate) (set renewal to manual to avoid recurring donation) Send me a Bitcoin? Aha ha, just kidding… unless..? diff --git a/app/app/build.gradle b/app/app/build.gradle deleted file mode 100644 index be9ddc852..000000000 --- a/app/app/build.gradle +++ /dev/null @@ -1,280 +0,0 @@ -plugins { - id 'com.ncorti.ktfmt.gradle' version '0.17.0' - id 'com.google.dagger.hilt.android' - -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' -apply plugin: "androidx.navigation.safeargs.kotlin" -apply plugin: 'com.google.protobuf' -apply plugin: 'com.mikepenz.aboutlibraries.plugin' -apply plugin: 'kotlin-parcelize' - -protobuf { - protoc { - artifact = deps.protobuf.protoc - } - - generateProtoTasks { - all().each { task -> - task.builtins { - java { - option 'lite' - } - } - } - } -} - -def keyPropertiesFile = rootProject.file("signingkey.properties") -def keyProperties = new Properties() -if (keyPropertiesFile.exists()) { - keyProperties.load(new FileInputStream(keyPropertiesFile)) -} - -def apiPropertiesFile = rootProject.file("apikey.properties") -def apiProperties = new Properties() -if (apiPropertiesFile.exists()) { - apiProperties.load(new FileInputStream(apiPropertiesFile)) -} - -ktfmt { - // KotlinLang style - 4 space indentation - From kotlinlang.org/docs/coding-conventions.html - kotlinLangStyle() -} - -android { - namespace "com.github.livingwithhippos.unchained" - - compileSdk 34 - - defaultConfig { - applicationId "com.github.livingwithhippos.unchained" - minSdk 22 - targetSdk 34 - versionCode 43 - versionName "1.2.1" - // limit resources for a list of locales - // resConfigs "en", "it" - - javaCompileOptions { - annotationProcessorOptions { - arguments += ["room.schemaLocation": "$projectDir/schemas".toString(), - "room.incremental" : "true"] - } - } - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - - packagingOptions { - // Exclude AndroidX version files - exclude 'META-INF/*.version' - // Exclude consumer proguard files - exclude 'META-INF/proguard/*' - // Exclude the Firebase/Fabric/other random properties files - exclude '/*.properties' - exclude 'fabric/*.properties' - exclude 'META-INF/*.properties' - } - - signingConfigs { - // use local file if available or Environment variables (for CI) - release { - if (keyPropertiesFile.exists()) { - // windows file path - storeFile file(keyProperties['releaseStoreFile']) - // linux file path - // storeFile file("/home/user/.keystore/release.pfk") - // shared properties - storePassword keyProperties['releaseStorePassword'] - keyAlias keyProperties['keyAlias'] - keyPassword keyProperties['releaseStorePassword'] - } else { - storeFile file(System.getenv("KEYSTORE") ?: "release.pfk") - storePassword System.getenv("KEYSTORE_PASSWORD") - keyAlias System.getenv("KEY_ALIAS") - keyPassword System.getenv("KEY_PASSWORD") - } - } - } - - buildTypes { - - applicationVariants.all { variant -> - variant.outputs.all { output -> - outputFileName = applicationId - outputFileName += "-v" + - android.defaultConfig.versionName + - ".apk" - - } - } - - - debug { - versionNameSuffix "-dev" - applicationIdSuffix ".debug" - signingConfig signingConfigs.debug - - if (apiPropertiesFile.exists()) { - buildConfigField("String", "COUNTLY_APP_KEY", apiProperties['COUNTLY_APP_KEY']) - buildConfigField("String", "COUNTLY_URL", apiProperties['COUNTLY_URL']) - } else { - // use a random key and localhost if the env are missing - def countlyKey = System.getenv("COUNTLY_APP_KEY") ?: "pDJz4WrY9XeBotXAaL9MYrraSwZNyDqfAPy8p38c" - def countlyHost = System.getenv("COUNTLY_URL") ?: "http://localhost" - buildConfigField("String", "COUNTLY_APP_KEY", "\""+countlyKey+"\"") - buildConfigField("String", "COUNTLY_URL", "\""+countlyHost+"\"") - } - } - - release { - - // with the separated release/debug files this could be unnecessary now - buildConfigField("String", "COUNTLY_APP_KEY", "\"" + "not for production" + "\"") - buildConfigField("String", "COUNTLY_URL", "\"" + "not for production" + "\"") - - signingConfig signingConfigs.release - - debuggable false - - // Enables code shrinking, obfuscation, and optimization for only - // your project's release build type. - minifyEnabled true - - // Enables resource shrinking, which is performed by the - // Android Gradle plugin. - shrinkResources true - - // Includes the default ProGuard rules files that are packaged with - // the Android Gradle plugin. To learn more, go to the section about - // R8 configuration files. - proguardFiles getDefaultProguardFile( - 'proguard-android-optimize.txt'), - 'proguard-rules.pro' - - } - } - - buildFeatures { - dataBinding true - buildConfig true - } - - compileOptions { - sourceCompatibility = 1.8 - targetCompatibility = 1.8 - } - - kotlinOptions { - jvmTarget = "1.8" - } -} - -dependencies { - - implementation fileTree(dir: "libs", include: ["*.jar"]) - implementation deps.kotlin.stdlib - implementation deps.kotlin.reflect - implementation deps.core_ktx - implementation deps.app_compat - implementation deps.constraint_layout - implementation deps.fragment.runtime_ktx - implementation deps.swiperefreshlayout - implementation deps.preference_ktx - implementation deps.recyclerview.recyclerview - implementation deps.recyclerview.selection - implementation deps.viewpager2 - - // datastore - implementation deps.datastore.datastore - implementation deps.protobuf.javalite - - // datetime - implementation deps.datetime.core - - implementation deps.flexbox - - // moshi - implementation deps.moshi.runtime - kapt deps.moshi.kapt - - // retrofit - implementation deps.retrofit.runtime - implementation deps.retrofit.moshi - implementation deps.retrofit.scalars - - //okhttp - implementation deps.okhttp.runtime - implementation deps.okhttp.logging_interceptor - implementation deps.okhttp.doh - - // navigation - implementation deps.navigation.runtime_ktx - implementation deps.navigation.fragment_ktx - implementation deps.navigation.ui_ktx - - // room - implementation deps.room.runtime - implementation deps.room.ktx - kapt deps.room.compiler - - //coroutines - implementation deps.coroutines.android - implementation deps.coroutines.core - - // material design - implementation deps.material - - // Lifecycle stuff - implementation deps.lifecycle.viewmodel_ktx - implementation deps.lifecycle.viewmodel_savedstate - implementation deps.lifecycle.livedata_ktx - implementation deps.lifecycle.service - implementation deps.lifecycle.java8 - - // coil - implementation deps.coil - - //hilt - implementation deps.dagger.hilt_android - implementation deps.dagger.hilt_navigation - kapt deps.dagger.hilt_compiler - - // paging - implementation deps.paging_runtime - - // state-machine - implementation deps.statemachine - - // timber - implementation deps.timber - - //jsoup - implementation deps.jsoup - - // work manager - implementation deps.work.core - - // countly - debugImplementation deps.countly - - // about - implementation deps.aboutlibraries.core - implementation deps.aboutlibraries.ui - - // test - androidTestImplementation deps.test.core_ktx - androidTestImplementation deps.test.rules - androidTestImplementation deps.test.espresso - androidTestImplementation deps.test.junit - androidTestImplementation deps.test.runner - androidTestImplementation deps.test.truth - testImplementation deps.junit -} - -kapt { - correctErrorTypes true -} \ No newline at end of file diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts new file mode 100644 index 000000000..6f0d01726 --- /dev/null +++ b/app/app/build.gradle.kts @@ -0,0 +1,264 @@ +import java.util.Properties + + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.parcelize") + id("dagger.hilt.android.plugin") + id("androidx.navigation.safeargs.kotlin") + id("com.mikepenz.aboutlibraries.plugin") + alias(libs.plugins.protobuf) + alias(libs.plugins.ktfmt) + kotlin("kapt") +} + +fun readProperties(propertiesFile: File) = Properties().apply { + if (propertiesFile.exists()) + propertiesFile.inputStream().use { fis -> + load(fis) + } +} + +val keyPropertiesFile: File = rootProject.file("signingkey.properties") +val keyProperties = readProperties(keyPropertiesFile) + +val apiPropertiesFile: File = rootProject.file("apikey.properties") +val apiProperties = readProperties(apiPropertiesFile) + +protobuf { + protoc { + artifact = libs.protobuf.core.get().toString() + } + plugins { + generateProtoTasks { + all().forEach { + it.builtins { + create("java") { + option("lite") + } + } + } + } + } +} + +ktfmt { + // KotlinLang style - 4 space indentation - From kotlinlang.org/docs/coding-conventions.html + kotlinLangStyle() +} + +kapt { + correctErrorTypes = true +} + +android { + namespace = "com.github.livingwithhippos.unchained" + compileSdk = 34 + + defaultConfig { + applicationId = "com.github.livingwithhippos.unchained" + minSdk = 22 + targetSdk = 34 + versionCode = 44 + versionName = "1.3.0" + + javaCompileOptions { + annotationProcessorOptions { + arguments["room.schemaLocation"] = "$projectDir/schemas" + arguments["room.incremental"] = "true" + } + } + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + packaging { + jniLibs { + excludes.addAll(listOf("META-INF/proguard/*")) + } + resources { + excludes.addAll( + listOf( + "META-INF/*.version", + // manually added, markdown files should not be needed + // was crashing with the jakarta xml bind api + "META-INF/*.md", + "META-INF/proguard/*", + "/*.properties", + "fabric/*.properties", + "META-INF/*.properties" + ) + ) + } + } + signingConfigs { + // use local file if available or Environment variables (for CI) + create("release") { + if (keyPropertiesFile.exists()) { + storeFile = keyPropertiesFile + storePassword = keyProperties["releaseStorePassword"] as String + keyAlias = keyProperties["keyAlias"] as String + keyPassword = keyProperties["releaseStorePassword"] as String + } else { + storeFile = file(System.getenv("KEYSTORE") ?: "release.pfk") + storePassword = System.getenv("KEYSTORE_PASSWORD") + keyAlias = System.getenv("KEY_ALIAS") + keyPassword = System.getenv("KEY_PASSWORD") + } + } + } + + buildTypes { + applicationVariants.forEach { variant -> + variant.outputs.map { + it as com.android.build.gradle.internal.api.BaseVariantOutputImpl + }.forEach { + it.outputFileName = "${variant.name}-${variant.versionName}.apk" + } + } + + debug { + versionNameSuffix = "-dev" + applicationIdSuffix = ".debug" + signingConfig = signingConfigs.getByName("debug") + + buildConfigField( + "String", + "COUNTLY_APP_KEY", + apiProperties.getOrDefault( + "COUNTLY_APP_KEY", + "\"" + + (System.getenv("COUNTLY_APP_KEY") + ?: "pDJz4WrY9XeBotXAaL9MYrraSwZNyDqfAPy8p38c") + + "\"" + ) as String + ) + + buildConfigField( + "String", + "COUNTLY_URL", + apiProperties.getOrDefault( + "COUNTLY_URL", + "\"" + (System.getenv("COUNTLY_URL") ?: "http://localhost") + "\"" + ) as String + ) + + } + + + release { + signingConfig = signingConfigs.getByName("release") + isDebuggable = false + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + dataBinding = true + buildConfig = true + } +} + +dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + implementation(libs.kotlin.datetime) + + implementation(libs.core.ktx) + implementation(libs.android.appcompat) + + implementation(libs.android.constraintlayout) + implementation(libs.fragment.ktx) + + implementation(libs.swiperefresh.layout) + implementation(libs.preference.ktx) + + implementation(libs.recyclerview.core) + implementation(libs.recyclerview.selection) + implementation(libs.viewpager2) + implementation(libs.flexbox) + + 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) + + kapt(libs.moshi.kapt) + implementation(libs.moshi.runtime) + + implementation(libs.retrofit.runtime) + implementation(libs.retrofit.moshi) + implementation(libs.retrofit.scalars) + + implementation(libs.okhttp3.runtime) + implementation(libs.okhttp3.logging) + implementation(libs.okhttp3.doh) + + implementation(libs.navigation.runtime) + implementation(libs.navigation.fragment) + implementation(libs.navigation.ui) + + kapt(libs.room.compiler) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + + implementation(libs.coroutines.core) + implementation(libs.coroutines.android) + + implementation(libs.material.version3) + + implementation(libs.lifecycle.viewmodel) + implementation(libs.lifecycle.savedstate) + implementation(libs.lifecycle.livedata) + implementation(libs.lifecycle.service) + implementation(libs.lifecycle.java8) + + implementation(libs.coil) + + kapt(libs.hilt.compiler) + implementation(libs.hilt.navigation) + implementation(libs.hilt.android) + + implementation(libs.paging.runtime) + + implementation(libs.statemachine) + + implementation(libs.timber) + + implementation(libs.jsoup) + + implementation(libs.android.work) + + implementation(libs.countly) + + implementation(libs.protobuf.javaLite) + + implementation(libs.about.core) + implementation(libs.about.ui) + + androidTestImplementation(libs.test.core) + androidTestImplementation(libs.test.espresso) + androidTestImplementation(libs.test.runner) + androidTestImplementation(libs.test.rules) + androidTestImplementation(libs.test.junit) + androidTestImplementation(libs.test.truth) + testImplementation(libs.junit) +} \ No newline at end of file diff --git a/app/app/proguard-rules.pro b/app/app/proguard-rules.pro index 1e70c08ae..f61acb38f 100644 --- a/app/app/proguard-rules.pro +++ b/app/app/proguard-rules.pro @@ -33,3 +33,22 @@ # With R8 full mode generic signatures are stripped for classes that are not kept. -keep,allowobfuscation,allowshrinking class retrofit2.Response + +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.* +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE +-dontwarn aQute.bnd.annotation.spi.ServiceProvider + +# Please add these rules to your existing keep rules in order to suppress warnings. +# This is generated automatically by the Android Gradle plugin. +-dontwarn javax.xml.stream.FactoryConfigurationError +-dontwarn javax.xml.stream.Location +-dontwarn javax.xml.stream.XMLEventFactory +-dontwarn javax.xml.stream.XMLInputFactory +-dontwarn javax.xml.stream.XMLOutputFactory +-dontwarn javax.xml.stream.XMLResolver +-dontwarn javax.xml.stream.util.XMLEventAllocator \ No newline at end of file diff --git a/app/app/schemas/com.github.livingwithhippos.unchained.data.local.UnchaineDB/7.json b/app/app/schemas/com.github.livingwithhippos.unchained.data.local.UnchaineDB/7.json new file mode 100644 index 000000000..8589445e6 --- /dev/null +++ b/app/app/schemas/com.github.livingwithhippos.unchained.data.local.UnchaineDB/7.json @@ -0,0 +1,407 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "72b33d99768f1d69325ecd898b403bcd", + "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" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDefault", + "columnName": "is_default", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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" + ] + }, + "indices": [], + "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", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repository", + "plugin_name" + ] + }, + "indices": [], + "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" + ] + }, + "indices": [], + "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" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + }, + { + "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" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "remote_device", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "device_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '72b33d99768f1d69325ecd898b403bcd')" + ] + } +} \ No newline at end of file diff --git a/app/app/src/androidTest/java/com/github/livingwithhippos/unchained/base/MainActivityTest.kt b/app/app/src/androidTest/java/com/github/livingwithhippos/unchained/base/MainActivityTest.kt index 40a01cef3..542bbc213 100644 --- a/app/app/src/androidTest/java/com/github/livingwithhippos/unchained/base/MainActivityTest.kt +++ b/app/app/src/androidTest/java/com/github/livingwithhippos/unchained/base/MainActivityTest.kt @@ -45,7 +45,8 @@ class MainActivityTest { // if the app is already logged in this is going to fail, clear it with adb before launching // set the TOKEN arguments editing the MainActivityTest Configuration val args = InstrumentationRegistry.getArguments().getString("TOKEN") - onView(withId(R.id.tiPrivateCode)).perform(replaceText(args)) + assert(args != null) + onView(withId(R.id.tiPrivateCode)).perform(replaceText(args!!)) onView(withId(R.id.bInsertPrivate)).perform(click()) onView(isRoot()).perform(waitForView(R.id.tvMail, 5000)) onView(withId(R.id.tvMail)).check(matches(isDisplayed())) diff --git a/app/app/src/debug/java/com/github/livingwithhippos/unchained/utilities/TelemetryManager.kt b/app/app/src/debug/java/com/github/livingwithhippos/unchained/utilities/TelemetryManager.kt index 70edab3a6..d95c91a5e 100644 --- a/app/app/src/debug/java/com/github/livingwithhippos/unchained/utilities/TelemetryManager.kt +++ b/app/app/src/debug/java/com/github/livingwithhippos/unchained/utilities/TelemetryManager.kt @@ -2,11 +2,9 @@ package com.github.livingwithhippos.unchained.utilities import android.app.Activity import android.app.Application -import android.content.res.Configuration import com.github.livingwithhippos.unchained.BuildConfig import ly.count.android.sdk.Countly import ly.count.android.sdk.CountlyConfig -import ly.count.android.sdk.DeviceIdType import timber.log.Timber object TelemetryManager { @@ -18,21 +16,16 @@ object TelemetryManager { Countly.sharedInstance().onStop() } - fun onConfigurationChanged(newConfig: Configuration) { - Countly.sharedInstance().onConfigurationChanged(newConfig) - } - fun onCreate(application: Application) { // remove these lines from the release file Timber.plant(Timber.DebugTree()) val config: CountlyConfig = CountlyConfig(application, BuildConfig.COUNTLY_APP_KEY, BuildConfig.COUNTLY_URL) - .setIdMode(DeviceIdType.OPEN_UDID) - .enableCrashReporting() // if true will print internal countly logs to the console .setLoggingEnabled(false) // .setParameterTamperingProtectionSalt("SampleSalt") + config.crashes.enableCrashReporting() Countly.sharedInstance().init(config) } diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml index 9e298145e..c1103a13d 100644 --- a/app/app/src/main/AndroidManifest.xml +++ b/app/app/src/main/AndroidManifest.xml @@ -14,11 +14,10 @@ + - + + android:theme="@style/Theme.Unchained.Material3.Green.One" + > + @@ -62,6 +62,21 @@ + + + + + + + + + + + + + + @@ -261,7 +276,6 @@ - @@ -340,6 +354,12 @@ android:exported="false" android:foregroundServiceType="dataSync" /> + + + 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 9f19711cc..2a58b31cf 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 @@ -43,7 +43,7 @@ class AuthenticationFragment : UnchainedFragment(), ButtonListener { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { val authBinding = FragmentAuthenticationBinding.inflate(inflater, container, false) @@ -115,7 +115,7 @@ class AuthenticationFragment : UnchainedFragment(), ButtonListener { // set up values for calling the secrets endpoint viewModel.setupSecretLoop(auth.expiresIn) } - } + }, ) // 2. start checking for user confirmation @@ -151,7 +151,7 @@ class AuthenticationFragment : UnchainedFragment(), ButtonListener { // update the currently saved credentials activityViewModel.updateCredentials( clientId = secrets.value.clientId, - clientSecret = secrets.value.clientSecret + clientSecret = secrets.value.clientSecret, ) // start the next auth step activityViewModel.transitionAuthenticationMachine( @@ -161,7 +161,7 @@ class AuthenticationFragment : UnchainedFragment(), ButtonListener { } } } - } + }, ) // 3. start checking for the authentication token @@ -178,7 +178,7 @@ class AuthenticationFragment : UnchainedFragment(), ButtonListener { FSMAuthenticationEvent.OnOpenTokenLoaded ) } - } + }, ) return authBinding.root @@ -197,7 +197,7 @@ class AuthenticationFragment : UnchainedFragment(), ButtonListener { ForegroundColorSpan(colorSecondary), 0, link.length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, ) sb.append(link) @@ -219,7 +219,7 @@ class AuthenticationFragment : UnchainedFragment(), ButtonListener { clientId = PRIVATE_TOKEN, clientSecret = PRIVATE_TOKEN, deviceCode = PRIVATE_TOKEN, - refreshToken = PRIVATE_TOKEN + refreshToken = PRIVATE_TOKEN, ) activityViewModel.transitionAuthenticationMachine(FSMAuthenticationEvent.OnPrivateToken) } 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 349cac238..9c4320a45 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 @@ -70,7 +70,7 @@ constructor( authRepository.getToken( credentials.clientId, credentials.clientSecret, - credentials.deviceCode + credentials.deviceCode, ) tokenLiveData.postEvent(tokenData) } @@ -87,12 +87,11 @@ constructor( var calls = (expiresIn * 1000 / SECRET_CALLS_DELAY).toInt() - 10 // remove 10% of the calls to account for the api calls calls -= calls / 10 - savedStateHandle.set(SECRET_CALLS_MAX, calls) - savedStateHandle.set(SECRET_CALLS, 0) + savedStateHandle[SECRET_CALLS_MAX] = calls + savedStateHandle[SECRET_CALLS] = 0 } companion object { - const val AUTH_STATE = "auth_state" const val SECRET_CALLS = "secret_calls" const val SECRET_CALLS_MAX = "max_secret_calls" @@ -102,9 +101,9 @@ constructor( } sealed class SecretResult { - object Empty : SecretResult() + data object Empty : SecretResult() - object Expired : SecretResult() + data object Expired : SecretResult() data class Retrieved(val value: Secrets) : SecretResult() } 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 9bfdbca79..608e7814e 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 @@ -11,11 +11,11 @@ import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences import android.content.pm.PackageManager -import android.content.res.Configuration import android.net.ConnectivityManager import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.Parcelable import android.view.Menu import android.view.MenuInflater import android.view.MenuItem @@ -62,7 +62,9 @@ import com.github.livingwithhippos.unchained.utilities.extension.showToast import com.github.livingwithhippos.unchained.utilities.extension.toHex import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.elevation.SurfaceColors import dagger.hilt.android.AndroidEntryPoint +import java.lang.RuntimeException import java.security.MessageDigest import javax.inject.Inject import kotlinx.coroutines.delay @@ -80,6 +82,11 @@ class MainActivity : AppCompatActivity() { override fun onStart() { super.onStart() TelemetryManager.onStart(this) + + val bottomColor = SurfaceColors.SURFACE_2.getColor(this) + window.navigationBarColor = bottomColor + // Set color of system navigationBar same as BottomNavigationView + // window.statusBarColor = color // Set color of system statusBar same as ActionBar } override fun onStop() { @@ -134,11 +141,6 @@ class MainActivity : AppCompatActivity() { return emptyList() } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - TelemetryManager.onConfigurationChanged(newConfig) - } - private lateinit var binding: ActivityMainBinding val viewModel: MainActivityViewModel by viewModels() @@ -230,8 +232,7 @@ class MainActivity : AppCompatActivity() { // do not inline this variable in the when, because getContentIfNotHandled() will change // its // value to null if checked again in WaitingUserAction - val authState: FSMAuthenticationState? = it?.getContentIfNotHandled() - when (authState) { + when (val authState: FSMAuthenticationState? = it?.getContentIfNotHandled()) { null -> { // do nothing } @@ -296,6 +297,9 @@ class MainActivity : AppCompatActivity() { // unlock the bottom menu enableAllBottomNavItems() } + is FSMAuthenticationState.CheckCredentials -> { + // avoid issues with restoring activity state + } else -> { // todo: decide if we need to check other possible values or reset the fsm to // checkCredentials in these states and call startAuthenticationMachine @@ -307,13 +311,14 @@ class MainActivity : AppCompatActivity() { // check if the app has been opened by clicking on torrents/magnet on sharing links getIntentData() - // observe for torrents downloaded + // observe for downloaded torrents + try { ContextCompat.registerReceiver( applicationContext, downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), - ContextCompat.RECEIVER_NOT_EXPORTED + ContextCompat.RECEIVER_NOT_EXPORTED, ) } catch (ex: RuntimeException) { Timber.w(ex, "Download receiver already registered") @@ -347,7 +352,7 @@ class MainActivity : AppCompatActivity() { lifecycleScope.launch { doubleClickBottomItem(R.id.navigation_search) } } } - } + }, ) // monitor if the torrent notification service needs to be started. It monitor the @@ -399,19 +404,19 @@ class MainActivity : AppCompatActivity() { SIGNATURE.F_DROID -> { showUpdateDialog( getString(R.string.fdroid_update_description), - APP_LINK.F_DROID + APP_LINK.F_DROID, ) } SIGNATURE.PLAY_STORE -> { showUpdateDialog( getString(R.string.playstore_update_description), - APP_LINK.PLAY_STORE + APP_LINK.PLAY_STORE, ) } SIGNATURE.GITHUB -> { showUpdateDialog( getString(R.string.github_update_description), - APP_LINK.GITHUB + APP_LINK.GITHUB, ) } else -> {} @@ -438,7 +443,7 @@ class MainActivity : AppCompatActivity() { Build.VERSION.SDK_INT in 23..28 && ContextCompat.checkSelfPermission( applicationContext, - Manifest.permission.WRITE_EXTERNAL_STORAGE + Manifest.permission.WRITE_EXTERNAL_STORAGE, ) != PERMISSION_GRANTED ) { viewModel.requireDownloadPermissions() @@ -456,7 +461,7 @@ class MainActivity : AppCompatActivity() { source = Uri.parse(download.download), title = download.filename, description = getString(R.string.app_name), - fileName = download.filename + fileName = download.filename, ) when (queuedDownload) { is EitherResult.Failure -> { @@ -474,7 +479,7 @@ class MainActivity : AppCompatActivity() { getString( R.string.multiple_downloads_enqueued_format, downloadsStarted, - content.downloads.size + content.downloads.size, ) ) } @@ -495,7 +500,7 @@ class MainActivity : AppCompatActivity() { } viewModel.startMultipleDownloadWorkers( folder, - content.downloads + content.downloads, ) } else viewModel.requireDownloadFolder() } @@ -508,7 +513,7 @@ class MainActivity : AppCompatActivity() { Build.VERSION.SDK_INT in 23..28 && ContextCompat.checkSelfPermission( applicationContext, - Manifest.permission.WRITE_EXTERNAL_STORAGE + Manifest.permission.WRITE_EXTERNAL_STORAGE, ) != PERMISSION_GRANTED ) { viewModel.requireDownloadPermissions() @@ -525,14 +530,14 @@ class MainActivity : AppCompatActivity() { source = Uri.parse(content.source), title = content.fileName, description = getString(R.string.app_name), - fileName = content.fileName + fileName = content.fileName, ) when (queuedDownload) { is EitherResult.Failure -> { applicationContext.showToast( getString( R.string.download_not_started_format, - content.fileName + content.fileName, ) ) } @@ -580,8 +585,7 @@ class MainActivity : AppCompatActivity() { ContextCompat.startForegroundService(this, notificationIntent) } - // load the old share preferences of kodi devices into the db and then delete them - viewModel.updateOldKodiPreferences() + viewModel.migrateKodiPreferences() viewModel.connectivityLiveData.observe(this) { when (it) { @@ -620,10 +624,24 @@ class MainActivity : AppCompatActivity() { when (intent?.action) { // shared text link Intent.ACTION_SEND -> { - if (intent.type == "text/plain") - intent.getStringExtra(Intent.EXTRA_TEXT)?.let { text -> - viewModel.downloadSupportedLink(text) + when (intent.type) { + "text/plain" -> { + intent.getStringExtra(Intent.EXTRA_TEXT)?.let { text -> + viewModel.downloadSupportedLink(text) + } } + "*/*" -> { + // replace with intent.getParcelableExtra(Intent.EXTRA_STREAM, + // Uri::class.java) when stabilized + (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { + if ( + it.lastPathSegment?.endsWith(TYPE_UNCHAINED, ignoreCase = true) == + true + ) + viewModel.addPluginFromDisk(applicationContext, it) + } + } + } } // files clicked Intent.ACTION_VIEW -> { @@ -775,7 +793,7 @@ class MainActivity : AppCompatActivity() { R.id.start_dest, R.id.user_dest, R.id.list_tabs_dest, - R.id.search_dest + R.id.search_dest, ) ) setupActionBarWithNavController(navController, appBarConfiguration) @@ -804,10 +822,10 @@ class MainActivity : AppCompatActivity() { } } + @Deprecated("Deprecated in Java") override fun onBackPressed() { - // if the user is pressing back on an "exiting"fragment, show a toast alerting him and wait - // for - // him to press back again for confirmation + // if the user is pressing back on an "exiting" fragment, show a toast alerting him and wait + // for him to press back again for confirmation val currentDestination = navController.currentDestination val previousDestination = navController.previousBackStackEntry diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/ThemingCallback.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/ThemingCallback.kt index 29dc16852..569e18a09 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/base/ThemingCallback.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/base/ThemingCallback.kt @@ -3,36 +3,38 @@ package com.github.livingwithhippos.unchained.base import android.app.Activity import android.app.Application import android.content.SharedPreferences -import android.content.res.Resources import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import com.github.livingwithhippos.unchained.R import com.github.livingwithhippos.unchained.settings.view.SettingsFragment -import com.github.livingwithhippos.unchained.utilities.extension.getThemeColor +import com.github.livingwithhippos.unchained.settings.view.SettingsFragment.Companion.THEME_AUTO +import com.github.livingwithhippos.unchained.settings.view.SettingsFragment.Companion.THEME_DAY +import com.github.livingwithhippos.unchained.settings.view.SettingsFragment.Companion.THEME_NIGHT +import com.github.livingwithhippos.unchained.settings.view.ThemeItem +import com.github.livingwithhippos.unchained.utilities.extension.getThemeList class ThemingCallback(val preferences: SharedPreferences) : Application.ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - preferences.getString(SettingsFragment.KEY_THEME, "waves_01")?.let { - setupNightMode(activity.resources, it) - if (activity is AppCompatActivity) setCustomTheme(activity, it) - } + val themeRes = + preferences.getInt( + SettingsFragment.KEY_THEME_NEW, + R.style.Theme_Unchained_Material3_Green_One, + ) + val themesList = activity.applicationContext.getThemeList() + val currentTheme: ThemeItem = themesList.find { it.themeID == themeRes } ?: themesList[1] + setupNightMode(currentTheme.nightMode) + if (activity is AppCompatActivity) setCustomTheme(activity, themeRes) } - private fun setCustomTheme(activity: Activity, theme: String) { - val themeID = - when (theme) { - "original" -> R.style.Theme_Unchained - "tropical_sunset" -> R.style.Theme_TropicalSunset - "black_n_white" -> R.style.Theme_BlackAndWhite - "waves_01" -> R.style.Theme_Wave01 - else -> R.style.Theme_Wave01 - } - activity.setTheme(themeID) + private fun setCustomTheme(activity: Activity, themeRes: Int) { + activity.setTheme(themeRes) + /* // todo: check if this can be avoided, android:navigationBarColor in xml is not working activity.window.statusBarColor = activity.getThemeColor(R.attr.customStatusBarColor) activity.window.navigationBarColor = activity.getThemeColor(R.attr.customNavigationBarColor) + */ } override fun onActivityStarted(activity: Activity) {} @@ -47,35 +49,23 @@ class ThemingCallback(val preferences: SharedPreferences) : Application.Activity override fun onActivityDestroyed(activity: Activity) {} - private fun setupNightMode(resources: Resources, theme: String) { - // get night mode values - val nightModeArray = resources.getStringArray(R.array.night_mode_values) - val nightMode = preferences.getString(SettingsFragment.KEY_DAY_NIGHT, "auto") - // get current theme and check if it's has a night mode - if (theme in DAY_ONLY_THEMES) { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - // update the night mode if not the day one - if (nightMode != "day") - with(preferences.edit()) { - putString(SettingsFragment.KEY_DAY_NIGHT, "day") - apply() + /** Set the night mode depending on the user preferences and what the theme support */ + private fun setupNightMode(themeNightModeSupport: String) { + when (themeNightModeSupport) { + THEME_AUTO -> { + when (preferences.getString(SettingsFragment.KEY_DAY_NIGHT, "auto")) { + THEME_AUTO -> + AppCompatDelegate.setDefaultNightMode( + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + ) + THEME_DAY -> + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + THEME_NIGHT -> + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) } - } else { - // enable or disable night mode according with the preferences - when (nightMode) { - nightModeArray[0] -> - AppCompatDelegate.setDefaultNightMode( - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - ) - nightModeArray[1] -> - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - nightModeArray[2] -> - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) } + THEME_DAY -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + THEME_NIGHT -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) } } - - companion object { - val DAY_ONLY_THEMES = arrayOf("tropical_sunset", "waves_01") - } } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/customview/StatComponent.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/customview/StatComponent.kt new file mode 100644 index 000000000..1d594d562 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/customview/StatComponent.kt @@ -0,0 +1,136 @@ +package com.github.livingwithhippos.unchained.customview + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.github.livingwithhippos.unchained.R +import com.google.android.flexbox.AlignItems +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexboxLayoutManager +import com.google.android.flexbox.JustifyContent +import com.google.android.material.card.MaterialCardView +import com.google.android.material.divider.MaterialDividerItemDecoration + +class StatComponent +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + MaterialCardView(context, attrs, defStyleAttr) { + + private val recyclerView: RecyclerView + val adapter: StatAdapter + private val showLabel: Boolean + private val showCaption: Boolean + private val showIcon: Boolean + private val showDividers: Boolean + private val direction: Int + private val dividerItemDecoration: MaterialDividerItemDecoration + + init { + inflate(context, R.layout.stat_component, this) + + recyclerView = findViewById(R.id.recyclerView) + + context.theme.obtainStyledAttributes(attrs, R.styleable.StatComponent, 0, 0).apply { + try { + cardElevation = + resources.getDimensionPixelSize(R.dimen.stat_corner_radius).toFloat() + radius = resources.getDimensionPixelSize(R.dimen.stat_card_elevation).toFloat() + strokeWidth = 0 + + showLabel = getBoolean(R.styleable.StatItemComponent_show_label, true) + showCaption = getBoolean(R.styleable.StatItemComponent_show_caption, true) + showIcon = getBoolean(R.styleable.StatItemComponent_show_icon, true) + showDividers = getBoolean(R.styleable.StatComponent_show_dividers, true) + direction = getInteger(R.styleable.StatComponent_statDirection, 0) + + adapter = StatAdapter(showLabel, showCaption, showIcon) + recyclerView.adapter = adapter + + val layoutManager: FlexboxLayoutManager = FlexboxLayoutManager(context) + layoutManager.flexDirection = + if (direction == 1) FlexDirection.COLUMN else FlexDirection.ROW + layoutManager.justifyContent = JustifyContent.CENTER + layoutManager.alignItems = AlignItems.CENTER + + dividerItemDecoration = + MaterialDividerItemDecoration( + context, + if (direction == 1) MaterialDividerItemDecoration.VERTICAL + else MaterialDividerItemDecoration.HORIZONTAL, + ) + + if (showDividers) { + recyclerView.addItemDecoration(dividerItemDecoration) + } + recyclerView.layoutManager = layoutManager + } finally { + recycle() + } + } + } +} + +data class StatItem( + val content: String, + val label: String, + val caption: String, + @DrawableRes val icon: Int, +) + +class StatAdapter( + private val showLabel: Boolean, + private val showCaption: Boolean, + private val showIcon: Boolean, +) : ListAdapter(DiffCallback()) { + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: StatItem, newItem: StatItem): Boolean = + oldItem.content == newItem.content && + oldItem.label == newItem.label && + oldItem.caption == newItem.caption && + oldItem.icon == newItem.icon + + override fun areContentsTheSame(oldItem: StatItem, newItem: StatItem): Boolean = true + } + + /** Provide a reference to the type of views that you are using (custom ViewHolder) */ + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val labelTextView: TextView = view.findViewById(R.id.tvLabel) + val contentTextView: TextView = view.findViewById(R.id.tvContent) + val captionTextView: TextView = view.findViewById(R.id.tvCaption) + val iconImageView: ImageView = view.findViewById(R.id.ivIcon) + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder { + val view = + LayoutInflater.from(viewGroup.context) + .inflate(R.layout.stat_component_item, viewGroup, false) + + return ViewHolder(view) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val stat = getItem(position) + + viewHolder.contentTextView.text = stat.content + + if (showLabel.not()) viewHolder.labelTextView.visibility = View.GONE + else viewHolder.labelTextView.text = stat.label + + if (showCaption.not()) viewHolder.captionTextView.visibility = View.GONE + else viewHolder.captionTextView.text = stat.caption + + if (showIcon.not()) viewHolder.iconImageView.visibility = View.GONE + else viewHolder.iconImageView.setImageResource(stat.icon) + } + + override fun getItemViewType(position: Int) = R.layout.stat_component_item +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/customview/StatItemComponent.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/customview/StatItemComponent.kt new file mode 100644 index 000000000..4905c4f16 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/customview/StatItemComponent.kt @@ -0,0 +1,113 @@ +package com.github.livingwithhippos.unchained.customview + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.core.content.res.getResourceIdOrThrow +import com.github.livingwithhippos.unchained.R + +class StatItemComponent +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + LinearLayout(context, attrs, defStyleAttr) { + + private val tvLabel: TextView + private val tvContent: TextView + private val tvCaption: TextView + private val ivIcon: ImageView + + init { + inflate(context, R.layout.stat_component_item, this) + + context.theme.obtainStyledAttributes(attrs, R.styleable.StatItemComponent, 0, 0).apply { + try { + + tvLabel = findViewById(R.id.tvLabel) + tvContent = findViewById(R.id.tvContent) + tvCaption = findViewById(R.id.tvCaption) + ivIcon = findViewById(R.id.ivIcon) + + val showLabel = getBoolean(R.styleable.StatItemComponent_show_label, true) + val label = getString(R.styleable.StatItemComponent_label) ?: "" + val showCaption = getBoolean(R.styleable.StatItemComponent_show_caption, true) + val caption = getString(R.styleable.StatItemComponent_caption) ?: "" + val content = getString(R.styleable.StatItemComponent_item_content) ?: "" + val showIcon = getBoolean(R.styleable.StatItemComponent_show_icon, true) + val icon = getResourceIdOrThrow(R.styleable.StatItemComponent_item_icon) + + tvContent.text = content + + if (!showLabel) { + tvLabel.visibility = View.GONE + } else { + tvLabel.text = label + } + if (!showCaption) { + tvCaption.visibility = View.GONE + } else { + tvCaption.text = caption + } + + if (!showIcon) { + ivIcon.visibility = View.GONE + } else { + ivIcon.setImageResource(icon) + } + } finally { + recycle() + } + } + } + + fun setLabel(label: String) { + tvLabel.text = label + invalidate() + requestLayout() + } + + fun showLabel(show: Boolean) { + tvLabel.visibility = if (show) View.VISIBLE else View.GONE + invalidate() + requestLayout() + } + + fun setContent(content: String) { + tvContent.text = content + invalidate() + requestLayout() + } + + fun showContent(show: Boolean) { + tvContent.visibility = if (show) View.VISIBLE else View.GONE + invalidate() + requestLayout() + } + + fun setCaption(caption: String) { + tvCaption.text = caption + invalidate() + requestLayout() + } + + fun showCaption(show: Boolean) { + tvCaption.visibility = if (show) View.VISIBLE else View.GONE + invalidate() + requestLayout() + } + + fun setIcon(@DrawableRes icon: Int) { + ivIcon.setImageResource(icon) + invalidate() + requestLayout() + } + + fun showIcon(show: Boolean) { + ivIcon.visibility = if (show) View.VISIBLE else View.GONE + invalidate() + requestLayout() + } +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/customview/ThemeColorsCircle.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/customview/ThemeColorsCircle.kt new file mode 100644 index 000000000..d95a5827c --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/customview/ThemeColorsCircle.kt @@ -0,0 +1,97 @@ +package com.github.livingwithhippos.unchained.customview + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.util.AttributeSet +import android.view.View +import androidx.core.content.ContextCompat +import com.github.livingwithhippos.unchained.R + +class ThemeColorsCircle +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + View(context, attrs, defStyleAttr) { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + var topColor: Int + var bottomLeftColor: Int + var bottomRightColor: Int + + init { + + context.theme.obtainStyledAttributes(attrs, R.styleable.ThemeColorsCircle, 0, 0).apply { + try { + + topColor = + getColor( + R.styleable.ThemeColorsCircle_topColor, + ContextCompat.getColor(context, R.color.green_one_theme_primary), + ) + bottomLeftColor = + getColor( + R.styleable.ThemeColorsCircle_bottomLeftColor, + ContextCompat.getColor(context, R.color.green_one_theme_surface), + ) + bottomRightColor = + getColor( + R.styleable.ThemeColorsCircle_bottomRightColor, + ContextCompat.getColor(context, R.color.green_one_theme_primaryContainer), + ) + } finally { + recycle() + } + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val width = width.toFloat() + val height = height.toFloat() + + val radius = width.coerceAtMost(height) / 2 + val centerX = width / 2 + val centerY = height / 2 + + // Draw top half circle + paint.color = topColor + canvas.drawArc( + centerX - radius, + centerY - radius, + centerX + radius, + centerY + radius, + 180f, + 180f, + true, + paint, + ) + + // Draw bottom left quarter circle + paint.color = bottomLeftColor + canvas.drawArc( + centerX - radius, + centerY - radius, + centerX + radius, + centerY + radius, + 0f, + 90f, + true, + paint, + ) + + // Draw bottom right quarter circle + paint.color = bottomRightColor + canvas.drawArc( + centerX - radius, + centerY - radius, + centerX + radius, + centerY + radius, + 90f, + 90f, + true, + paint, + ) + } +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/AssetsManager.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/AssetsManager.kt index 5f75dffbc..fe47798c8 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/AssetsManager.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/AssetsManager.kt @@ -32,7 +32,7 @@ class AssetsManager @Inject constructor(@ApplicationContext private val appConte fun searchFiles( fileType: String, folder: String, - skipSystemFolders: Boolean = true + skipSystemFolders: Boolean = true, ): List { val results: MutableList = mutableListOf() val pathList = getAssetsPath(folder) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/KodiDeviceDao.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/KodiDeviceDao.kt index 60719891d..0af5d4ec0 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/KodiDeviceDao.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/KodiDeviceDao.kt @@ -45,6 +45,6 @@ interface KodiDeviceDao { username: String?, password: String?, isDefault: Int, - oldDeviceName: String + oldDeviceName: String, ) } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/ProtoStore.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/ProtoStore.kt index f1ceec20e..6a471ef58 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/ProtoStore.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/ProtoStore.kt @@ -11,7 +11,7 @@ interface ProtoStore { clientId: String? = null, clientSecret: String? = null, accessToken: String? = null, - refreshToken: String? = null + refreshToken: String? = null, ) suspend fun updateCredentials( @@ -19,7 +19,7 @@ interface ProtoStore { clientId: String? = null, clientSecret: String? = null, accessToken: String? = null, - refreshToken: String? = null + refreshToken: String? = null, ) suspend fun updateDeviceCode(deviceCode: String) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/ProtoStoreImpl.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/ProtoStoreImpl.kt index 933beb114..873f44262 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/ProtoStoreImpl.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/ProtoStoreImpl.kt @@ -26,7 +26,7 @@ class ProtoStoreImpl @Inject constructor(@ApplicationContext private val context clientId: String?, clientSecret: String?, accessToken: String?, - refreshToken: String? + refreshToken: String?, ) { context.credentialsDataStore.updateData { credentials -> credentials @@ -45,7 +45,7 @@ class ProtoStoreImpl @Inject constructor(@ApplicationContext private val context clientId: String?, clientSecret: String?, accessToken: String?, - refreshToken: String? + refreshToken: String?, ) { context.credentialsDataStore.updateData { credentials -> val builder = credentials.toBuilder() diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RemoteDeviceDao.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RemoteDeviceDao.kt new file mode 100644 index 000000000..53574205c --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RemoteDeviceDao.kt @@ -0,0 +1,131 @@ +package com.github.livingwithhippos.unchained.data.local + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Upsert +import java.util.Objects +import kotlinx.coroutines.flow.Flow +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +@Entity(tableName = "remote_device") +class RemoteDevice( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Int, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "address") val address: String, + @ColumnInfo(name = "is_default") val isDefault: Boolean = false, + @Ignore @IgnoredOnParcel val services: Int? = null, +) : Parcelable { + constructor( + id: Int, + name: String, + address: String, + isDefault: Boolean, + ) : this(id, name, address, isDefault, null) + + override fun equals(other: Any?): Boolean { + if (other is RemoteDevice) { + return other.id == id + } + return false + } + + override fun hashCode(): Int = Objects.hash(id) +} + +@Dao +interface RemoteDeviceDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertDevice(device: RemoteDevice): Long + + @Upsert suspend fun upsertDevice(device: RemoteDevice): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertService(service: RemoteService): Long + + @Delete suspend fun deleteService(service: RemoteService) + + @Delete suspend fun deleteDevice(device: RemoteDevice) + + @Query("DELETE FROM remote_device WHERE id = :deviceID") suspend fun deleteDevice(deviceID: Int) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllDevices(list: List): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllServices(list: List): List + + @Query( + "SELECT * FROM remote_device LEFT JOIN remote_service ON remote_device.id = remote_service.device_id" + ) + suspend fun getDevicesAndServices(): Map> + + @Query( + "SELECT * FROM remote_device LEFT JOIN remote_service ON remote_device.id = remote_service.device_id WHERE remote_service.type IN (:types)" + ) + suspend fun getMediaPlayerDevicesAndServices( + types: List + ): Map> + + @Query( + "SELECT * FROM remote_device LEFT JOIN remote_service ON remote_device.id = remote_service.device_id WHERE remote_service.type IN (:types)" + ) + fun getMediaPlayerDevicesAndServicesFlow( + types: List + ): Flow>> + + @Query("SELECT id FROM remote_device WHERE rowid = :rowId") + suspend fun getDeviceIDByRow(rowId: Long): Int? + + @Query("SELECT id FROM remote_service WHERE rowid = :rowId") + suspend fun getServiceIDByRow(rowId: Long): Int? + + @Query("SELECT * FROM remote_service WHERE id = :serviceID") + suspend fun getService(serviceID: Int): RemoteService? + + @Query("SELECT * FROM remote_device WHERE id = :deviceID") + suspend fun getDevice(deviceID: Int): RemoteDevice? + + @Query("SELECT * FROM remote_service WHERE remote_service.device_id = :deviceId") + suspend fun getDeviceServices(deviceId: Int): List + + @Query("SELECT * from remote_device") suspend fun getAllDevices(): List + + @Query("SELECT * from remote_service") suspend fun getAllServices(): List + + /** Delete has cascade so this will also delete the services */ + @Query("DELETE FROM remote_device") suspend fun deleteAll() + + /** Delete has cascade so this will also delete the services */ + @Query("DELETE FROM remote_device WHERE id = :id") suspend fun removeDevice(id: Int) + + @Query("DELETE FROM remote_service WHERE id = :id") suspend fun removeService(id: Int) + + @Query("DELETE FROM remote_service WHERE device_id = :deviceId") + suspend fun removeDeviceServices(deviceId: Int) + + @Query("SELECT * from remote_device WHERE remote_device.is_default = 1 LIMIT 1") + suspend fun getDefaultDevice(): RemoteDevice? + + @Query( + "SELECT * FROM remote_device LEFT JOIN remote_service ON remote_device.id = remote_service.device_id WHERE remote_device.is_default = 1 LIMIT 1" + ) + suspend fun getDefaultDeviceWithServices(): Map> + + @Query("UPDATE remote_device SET is_default = CASE WHEN id = :deviceId THEN 1 ELSE 0 END;") + suspend fun setDefaultDevice(deviceId: Int) + + @Query( + "UPDATE remote_service SET is_default = CASE WHEN id = :serviceId THEN 1 ELSE 0 END WHERE device_id = :deviceId;" + ) + suspend fun setDefaultDeviceService(deviceId: Int, serviceId: Int) +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RemoteServiceEntity.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RemoteServiceEntity.kt new file mode 100644 index 000000000..979a4e1e3 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RemoteServiceEntity.kt @@ -0,0 +1,81 @@ +package com.github.livingwithhippos.unchained.data.local + +import android.os.Parcelable +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import com.github.livingwithhippos.unchained.R +import java.util.Objects +import kotlinx.parcelize.Parcelize + +@Parcelize +@Entity( + tableName = "remote_service", + foreignKeys = + [ + ForeignKey( + entity = RemoteDevice::class, + parentColumns = ["id"], + childColumns = ["device_id"], + onDelete = ForeignKey.CASCADE, + ) + ], +) +class RemoteService( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Int, + @ColumnInfo(name = "device_id") val device: Int, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "port") val port: Int, + @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 = "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 RemoteService) { + return other.id == id + } + return false + } + + override fun hashCode(): Int = Objects.hash(id) +} + +sealed class RemoteServiceType( + val value: Int, + val isMediaPlayer: Boolean, + @StringRes val nameRes: Int, + @DrawableRes val iconRes: Int, +) { + data object KODI : RemoteServiceType(0, true, R.string.kodi, R.drawable.icon_kodi) + + data object VLC : RemoteServiceType(1, true, R.string.player_vlc, R.drawable.icon_vlc) + + data object JACKETT : RemoteServiceType(2, false, R.string.jackett, R.drawable.icon_jackett) +} + +val serviceTypeMap = + mapOf( + RemoteServiceType.KODI.value to RemoteServiceType.KODI, + RemoteServiceType.VLC.value to RemoteServiceType.VLC, + RemoteServiceType.JACKETT.value to RemoteServiceType.JACKETT, + ) + +/** Helper class to have all the service details together */ +data class RemoteServiceDetails( + val service: RemoteService, + val device: RemoteDevice, + val type: RemoteServiceType, +) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RepositoryDataDao.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RepositoryDataDao.kt index 122e1c250..9798a75a8 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RepositoryDataDao.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RepositoryDataDao.kt @@ -17,7 +17,9 @@ interface RepositoryDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(repository: Repository) - @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(plugin: RepositoryPlugin) + // since the repository plugin has only the search enabled column that can be changed and it's a + // user setting we can safely skip inserting on conflicts + @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(plugin: RepositoryPlugin) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(repository: RepositoryInfo) @@ -46,10 +48,13 @@ interface RepositoryDataDao { @Delete suspend fun delete(repository: Repository) + @Query("SELECT * FROM repository_info JOIN plugin ON plugin.repository = repository_info.link") + suspend fun getPlugins(): Map> + @Query( - "SELECT * FROM repository_info JOIN " + "plugin ON plugin.repository = repository_info.link" + "SELECT * FROM repository_info JOIN plugin ON plugin.repository = repository_info.link WHERE plugin.search_enabled = 1" ) - suspend fun getPlugins(): Map> + suspend fun getEnabledPlugins(): Map> @Query( "SELECT * FROM repository_info JOIN " + @@ -90,6 +95,9 @@ interface RepositoryDataDao { ) suspend fun getRepositoryPluginsVersions(repositoryUrl: String): List + @Query("UPDATE plugin SET search_enabled = :enabled WHERE plugin_name = :name") + suspend fun enablePlugin(name: String, enabled: Boolean) + /* @Query( "SELECT * FROM repository_info " + @@ -105,5 +113,5 @@ interface RepositoryDataDao { data class LatestPluginVersion( @ColumnInfo(name = "plugin_link") val link: String, - @ColumnInfo(name = "version") val version: Float + @ColumnInfo(name = "version") val version: Float, ) 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 981c262f7..61f73bb57 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 @@ -19,11 +19,18 @@ import com.github.livingwithhippos.unchained.data.model.RepositoryPlugin Repository::class, RepositoryInfo::class, RepositoryPlugin::class, - PluginVersion::class + PluginVersion::class, + RemoteDevice::class, + RemoteService::class, ], - version = 6, + version = 7, exportSchema = true, - autoMigrations = [AutoMigration(from = 4, to = 5), AutoMigration(from = 5, to = 6)] + autoMigrations = + [ + AutoMigration(from = 4, to = 5), + AutoMigration(from = 5, to = 6), + AutoMigration(from = 6, to = 7), + ], ) abstract class UnchaineDB : RoomDatabase() { abstract fun hostRegexDao(): HostRegexDao @@ -31,4 +38,6 @@ abstract class UnchaineDB : RoomDatabase() { abstract fun kodiDeviceDao(): KodiDeviceDao abstract fun pluginRepositoryDao(): RepositoryDataDao + + abstract fun pluginRemoteDeviceDao(): RemoteDeviceDao } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/APIError.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/APIError.kt index 829163752..71840c741 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/APIError.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/APIError.kt @@ -9,7 +9,7 @@ import timber.log.Timber data class APIError( @Json(name = "error") val error: String, @Json(name = "error_details") val errorDetails: String?, - @Json(name = "error_code") val errorCode: Int? + @Json(name = "error_code") val errorCode: Int?, ) : UnchainedNetworkException // todo: this has been resolved by adding an interceptor, change class name at least diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Authentication.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Authentication.kt index a7a288f29..d45c245b4 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Authentication.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Authentication.kt @@ -14,7 +14,7 @@ data class Authentication( @Json(name = "interval") val interval: Int, @Json(name = "expires_in") val expiresIn: Int, @Json(name = "verification_url") val verificationUrl: String, - @Json(name = "direct_verification_url") val directVerificationUrl: String + @Json(name = "direct_verification_url") val directVerificationUrl: String, ) /** @@ -24,7 +24,7 @@ data class Authentication( @JsonClass(generateAdapter = true) data class Secrets( @Json(name = "client_id") val clientId: String, - @Json(name = "client_secret") val clientSecret: String + @Json(name = "client_secret") val clientSecret: String, ) /** @@ -36,7 +36,7 @@ data class Token( @Json(name = "access_token") val accessToken: String, @Json(name = "expires_in") val expiresIn: Int, @Json(name = "token_type") val tokenType: String, - @Json(name = "refresh_token") val refreshToken: String + @Json(name = "refresh_token") val refreshToken: String, ) enum class UserAction { @@ -46,5 +46,5 @@ enum class UserAction { IP_NOT_ALLOWED, UNKNOWN, NETWORK_ERROR, - RETRY_LATER + RETRY_LATER, } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/DownloadItem.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/DownloadItem.kt index 3864b68e1..d76acfd42 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/DownloadItem.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/DownloadItem.kt @@ -56,7 +56,7 @@ data class DownloadItem( /** jsonDate */ @Json(name = "generated") val generated: String?, @Json(name = "type") val type: String?, - @Json(name = "alternative") val alternative: List? + @Json(name = "alternative") val alternative: List?, ) : Parcelable @JsonClass(generateAdapter = true) @@ -66,5 +66,5 @@ data class Alternative( @Json(name = "filename") val filename: String, @Json(name = "download") val download: String, @Json(name = "mimeType") val mimeType: String?, - @Json(name = "quality") val quality: String? + @Json(name = "quality") val quality: String?, ) : Parcelable diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Host.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Host.kt index 49e0eb899..1c120d15b 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Host.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Host.kt @@ -17,11 +17,11 @@ data class HostStatus( @Json(name = "supported") val supported: String, @Json(name = "status") val status: String, @Json(name = "check_time") val checkTime: String, - @Json(name = "competitors_status") val competitorsStatus: Map + @Json(name = "competitors_status") val competitorsStatus: Map, ) @JsonClass(generateAdapter = true) data class Competitor( @Json(name = "status") val status: String, - @Json(name = "check_time") val checkTime: String + @Json(name = "check_time") val checkTime: String, ) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/HostRegex.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/HostRegex.kt index f568452e9..3350741c1 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/HostRegex.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/HostRegex.kt @@ -7,7 +7,7 @@ import androidx.room.PrimaryKey @Entity(tableName = "host_regex") class HostRegex( @PrimaryKey @ColumnInfo(name = "regex") val regex: String, - @ColumnInfo(name = "type") val type: Int = REGEX_TYPE_HOST + @ColumnInfo(name = "type") val type: Int = REGEX_TYPE_HOST, ) const val REGEX_TYPE_HOST = 0 diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/KodiModels.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/KodiModels.kt index 815fe7c3c..37430630b 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/KodiModels.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/KodiModels.kt @@ -16,13 +16,13 @@ data class KodiRequest( @Json(name = "jsonrpc") val jsonRPC: String = "2.0", @Json(name = "id") val id: Int = 616, @Json(name = "method") val method: String, - @Json(name = "params") val params: KodiParams + @Json(name = "params") val params: KodiParams, ) @JsonClass(generateAdapter = true) data class KodiParams( @Json(name = "item") val item: KodiItem? = null, - @Json(name = "properties") val properties: List? = null + @Json(name = "properties") val properties: List? = null, ) @JsonClass(generateAdapter = true) data class KodiItem(@Json(name = "file") val fileUrl: String) @@ -31,24 +31,24 @@ data class KodiParams( data class KodiResponse( @Json(name = "id") val id: Int, @Json(name = "jsonrpc") val jsonrpc: String, - @Json(name = "result") val result: String + @Json(name = "result") val result: String, ) @JsonClass(generateAdapter = true) data class KodiGenericResponse( @Json(name = "id") val id: Int, @Json(name = "jsonrpc") val jsonrpc: String, - @Json(name = "result") val result: Any + @Json(name = "result") val result: Any, ) @JsonClass(generateAdapter = true) data class KodiError( @Json(name = "error") val error: KodiErrorData, - @Json(name = "id") val type: String? + @Json(name = "id") val type: String?, ) @JsonClass(generateAdapter = true) data class KodiErrorData( @Json(name = "code") val code: Int, - @Json(name = "message") val message: String + @Json(name = "message") val message: String, ) 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 0341c7b2e..65a7aeb95 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 @@ -17,7 +17,7 @@ data class Repository(@PrimaryKey @ColumnInfo(name = "link") val link: String) entity = Repository::class, parentColumns = ["link"], childColumns = ["link"], - onDelete = CASCADE + onDelete = CASCADE, ) ], ) @@ -26,7 +26,7 @@ data class RepositoryInfo( @ColumnInfo(name = "version") val version: Double, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "description") val description: String, - @ColumnInfo(name = "author") val author: String + @ColumnInfo(name = "author") val author: String, ) @Entity( @@ -37,14 +37,15 @@ data class RepositoryInfo( entity = RepositoryInfo::class, parentColumns = ["link"], childColumns = ["repository"], - onDelete = CASCADE + onDelete = CASCADE, ) ], - primaryKeys = ["repository", "plugin_name"] + primaryKeys = ["repository", "plugin_name"], ) data class RepositoryPlugin( @ColumnInfo(name = "repository") val repository: String, - @ColumnInfo(name = "plugin_name") val name: String + @ColumnInfo(name = "plugin_name") val name: String, + @ColumnInfo(name = "search_enabled") val searchEnabled: Boolean? = null, ) @Entity( @@ -55,15 +56,15 @@ data class RepositoryPlugin( entity = RepositoryPlugin::class, parentColumns = ["repository", "plugin_name"], childColumns = ["plugin_repository", "plugin"], - onDelete = CASCADE + onDelete = CASCADE, ) ], - primaryKeys = ["plugin_repository", "plugin", "version"] + primaryKeys = ["plugin_repository", "plugin", "version"], ) data class PluginVersion( @ColumnInfo(name = "plugin_repository") val repository: String, @ColumnInfo(name = "plugin") val plugin: String, @ColumnInfo(name = "version") val version: Float, - @ColumnInfo(name = "engine") val engine: Double, - @ColumnInfo(name = "plugin_link") val link: String + @ColumnInfo(name = "engine") val engine: Float, + @ColumnInfo(name = "plugin_link") val link: String, ) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/SearchSettings.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/SearchSettings.kt new file mode 100644 index 000000000..7872cefbe --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/SearchSettings.kt @@ -0,0 +1,27 @@ +package com.github.livingwithhippos.unchained.data.model + +enum class Category(val id: Int) { + ALL(0), + ART(1), + ANIME(2), + DOUJINSHI(3), + MANGA(4), + SOFTWARE(5), + GAMES(6), + MOVIES(7), + PICTURES(8), + VIDEOS(9), + MUSIC(10), + TV(11), + BOOKS(12), +} + +enum class Sorting(val id: Int) { + DEFAULT(0), + SEEDERS(1), + SIZE_ASC(2), + SIZE_DESC(3), + AZ(4), + ZA(5), + DATE(6), +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Stream.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Stream.kt index 6732b7e34..ff16c8ebf 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Stream.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Stream.kt @@ -8,7 +8,7 @@ data class Stream( @Json(name = "h264WebM") val h264WebM: Quality, @Json(name = "liveMP4") val liveMP4: Quality, @Json(name = "apple") val apple: Quality, - @Json(name = "dash") val dash: Quality + @Json(name = "dash") val dash: Quality, ) @JsonClass(generateAdapter = true) data class Quality(@Json(name = "full") val link: String) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/TorrentItem.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/TorrentItem.kt index 398c3e4a2..b4ae183bf 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/TorrentItem.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/TorrentItem.kt @@ -49,7 +49,6 @@ import kotlinx.parcelize.Parcelize * the selected files one, while the /torrents one returns only the original file size (even if the * docs say otherwise) * - * @constructor Create empty Torrent item * @property id * @property filename * @property originalFilename @@ -66,6 +65,7 @@ import kotlinx.parcelize.Parcelize * @property ended * @property speed * @property seeders + * @constructor Create empty Torrent item */ @JsonClass(generateAdapter = true) @Parcelize @@ -85,7 +85,7 @@ data class TorrentItem( @Json(name = "links") val links: List, @Json(name = "ended") val ended: String?, @Json(name = "speed") val speed: Int?, - @Json(name = "seeders") val seeders: Int? + @Json(name = "seeders") val seeders: Int?, ) : Parcelable @JsonClass(generateAdapter = true) @@ -94,7 +94,7 @@ data class InnerTorrentFile( @Json(name = "id") val id: Int, @Json(name = "path") val path: String, @Json(name = "bytes") val bytes: Long, - @Json(name = "selected") val selected: Int + @Json(name = "selected") val selected: Int, ) : Parcelable @JsonClass(generateAdapter = true) @@ -106,5 +106,5 @@ data class UploadedTorrent(@Json(name = "id") val id: String, @Json(name = "uri" @Parcelize data class AvailableHost( @Json(name = "host") val host: String, - @Json(name = "max_file_size") val maxFileSize: Int + @Json(name = "max_file_size") val maxFileSize: Int, ) : Parcelable diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Updates.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Updates.kt index d3622ecc0..ff0f50c02 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Updates.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Updates.kt @@ -7,11 +7,11 @@ import com.squareup.moshi.JsonClass data class Updates( @Json(name = "play_store") val playStore: VersionData?, @Json(name = "f_droid") val fDroid: VersionData?, - @Json(name = "github") val github: VersionData? + @Json(name = "github") val github: VersionData?, ) @JsonClass(generateAdapter = true) data class VersionData( @Json(name = "signature") val signature: String, - @Json(name = "versionCode") val versionCode: Int + @Json(name = "versionCode") val versionCode: Int, ) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/User.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/User.kt index cc6999aa0..2133cb8e9 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/User.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/User.kt @@ -15,5 +15,5 @@ data class User( @Json(name = "avatar") val avatar: String, @Json(name = "type") val type: String, @Json(name = "premium") val premium: Int, - @Json(name = "expiration") val expiration: String + @Json(name = "expiration") val expiration: String, ) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/cache/InstantAvailability.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/cache/InstantAvailability.kt index c26958720..f63eafb93 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/cache/InstantAvailability.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/cache/InstantAvailability.kt @@ -15,7 +15,7 @@ import timber.log.Timber data class CachedTorrent( val btih: String, // i may have a list of providers, for now I always see only "rd" - val cachedAlternatives: List + val cachedAlternatives: List, ) : Parcelable @Parcelize data class CachedAlternative(val cachedFiles: List) : Parcelable 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 new file mode 100644 index 000000000..8c7298007 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/jackett/Indexers.kt @@ -0,0 +1,23 @@ +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 + +@JacksonXmlRootElement(localName = "indexers") +data class Indexers( + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "indexer") + val indexers: List +) + +data class Indexer( + @JacksonXmlProperty(isAttribute = true, localName = "id") val id: String, + @JacksonXmlProperty(isAttribute = true, localName = "configured") val configured: String, + @JacksonXmlProperty(localName = "title") val title: String, + @JacksonXmlProperty(localName = "description") val description: String, + @JacksonXmlProperty(localName = "link") val link: String, + @JacksonXmlProperty(localName = "type") val type: String, + @JacksonXmlProperty(localName = "caps") val capabilities: Capabilities, +) 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 new file mode 100644 index 000000000..a9c55f49f --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZCapabilities.kt @@ -0,0 +1,55 @@ +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 + +@JacksonXmlRootElement(localName = "caps") +data class Capabilities( + @JacksonXmlProperty(localName = "server") val server: Server, + @JacksonXmlProperty(localName = "limits") val limits: Limits, + @JacksonXmlProperty(localName = "searching") val searching: Searching, + @JacksonXmlProperty(localName = "categories") val categories: Categories, +) + +data class Server(@JacksonXmlProperty(isAttribute = true, localName = "title") val title: String) + +data class Limits( + @JacksonXmlProperty(isAttribute = true, localName = "default") val default: Int, + @JacksonXmlProperty(isAttribute = true, localName = "max") val max: Int, +) + +data class Searching( + @JacksonXmlProperty(localName = "search") val search: CapsSearch, + @JacksonXmlProperty(localName = "tv-search") val tvSearch: CapsSearch, + @JacksonXmlProperty(localName = "movie-search") val movieSearch: CapsSearch, + @JacksonXmlProperty(localName = "music-search") val musicSearch: CapsSearch, + @JacksonXmlProperty(localName = "audio-search") val audioSearch: CapsSearch, + @JacksonXmlProperty(localName = "book-search") val bookSearch: CapsSearch, +) + +data class CapsSearch( + @JacksonXmlProperty(isAttribute = true, localName = "available") val available: String, + @JacksonXmlProperty(isAttribute = true, localName = "supportedParams") + val supportedParams: String, + @JacksonXmlProperty(isAttribute = true, localName = "searchEngine") val searchEngine: String?, +) + +data class Categories( + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "category") + val category: List +) + +data class Category( + @JacksonXmlProperty(isAttribute = true, localName = "id") val id: Int, + @JacksonXmlProperty(isAttribute = true, localName = "name") val name: String, + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "subcat") + val subcat: List?, +) + +data class SubCategory( + @JacksonXmlProperty(isAttribute = true, localName = "id") val id: Int, + @JacksonXmlProperty(isAttribute = true, localName = "name") val name: String, +) 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 new file mode 100644 index 000000000..daa01a15d --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZSearch.kt @@ -0,0 +1,45 @@ +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 + +@JacksonXmlRootElement(localName = "rss") +data class SearchRSS(@JacksonXmlProperty(localName = "channel") val channel: Channel) + +data class Channel( + @JacksonXmlProperty(localName = "title") val title: String, + @JacksonXmlProperty(localName = "description") val description: String, + @JacksonXmlProperty(localName = "link") val link: String, + @JacksonXmlProperty(localName = "language") val language: String, + @JacksonXmlProperty(localName = "category") val category: String, + @JacksonXmlProperty(localName = "item") val items: List, +) + +data class Item( + @JacksonXmlProperty(localName = "title") val title: String, + @JacksonXmlProperty(localName = "guid") val guid: String, + @JacksonXmlProperty(localName = "type") val type: String, + @JacksonXmlProperty(localName = "comments") val comments: String, + @JacksonXmlProperty(localName = "pubDate") val pubDate: String, + @JacksonXmlProperty(localName = "size") val size: String, + @JacksonXmlProperty(localName = "description") val description: String, + @JacksonXmlProperty(localName = "link") val link: String, + @JacksonXmlProperty(localName = "category") val categories: List, + @JacksonXmlProperty(localName = "enclosure") val enclosure: Enclosure, + // todo: check what happens with empty responses, nullable? default emptyList()? + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(namespace = "torznab", localName = "attr") + val torznabAttributes: List, +) + +data class Enclosure( + @JacksonXmlProperty(isAttribute = true, localName = "url") val url: String, + @JacksonXmlProperty(isAttribute = true, localName = "length") val length: String, + @JacksonXmlProperty(isAttribute = true, localName = "type") val type: String, +) + +data class TorznabAttribute( + @JacksonXmlProperty(isAttribute = true, localName = "name") val name: String, + @JacksonXmlProperty(isAttribute = true, localName = "value") val value: String, +) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/AuthApiHelperImpl.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/AuthApiHelperImpl.kt index d0bb7e7af..53921ef9d 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/AuthApiHelperImpl.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/AuthApiHelperImpl.kt @@ -18,6 +18,6 @@ class AuthApiHelperImpl @Inject constructor(private val authenticationApi: Authe override suspend fun getToken( clientId: String, clientSecret: String, - code: String + code: String, ): Response = authenticationApi.getToken(clientId, clientSecret, code) } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/AuthenticationApi.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/AuthenticationApi.kt index 17525691f..f8725f926 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/AuthenticationApi.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/AuthenticationApi.kt @@ -21,13 +21,13 @@ interface AuthenticationApi { @GET("device/code") suspend fun getAuthentication( @Query("client_id") id: String = OPEN_SOURCE_CLIENT_ID, - @Query("new_credentials") newCredentials: String = "yes" + @Query("new_credentials") newCredentials: String = "yes", ): Response @GET("device/credentials") suspend fun getSecrets( @Query("client_id") id: String = OPEN_SOURCE_CLIENT_ID, - @Query("code") deviceCode: String + @Query("code") deviceCode: String, ): Response /** This is also used to refresh the token. */ @@ -37,6 +37,6 @@ interface AuthenticationApi { @Field("client_id") clientId: String, @Field("client_secret") clientSecret: String, @Field("code") code: String, - @Field("grant_type") grantType: String = OPEN_SOURCE_GRANT_TYPE + @Field("grant_type") grantType: String = OPEN_SOURCE_GRANT_TYPE, ): Response } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/DownloadApi.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/DownloadApi.kt index 78f3e50e1..18de2fbb7 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/DownloadApi.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/DownloadApi.kt @@ -25,7 +25,7 @@ interface DownloadApi { @Header("Authorization") token: String, @Query("offset") offset: Int?, @Query("page") page: Int? = 1, - @Query("limit") limit: Int = 50 + @Query("limit") limit: Int = 50, ): Response> /** diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/DownloadApiHelper.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/DownloadApiHelper.kt index a080dfd2d..870d43b1c 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/DownloadApiHelper.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/DownloadApiHelper.kt @@ -8,11 +8,8 @@ interface DownloadApiHelper { token: String, offset: Int?, page: Int, - limit: Int + limit: Int, ): Response> - suspend fun deleteDownload( - token: String, - id: String, - ): Response + suspend fun deleteDownload(token: String, id: String): Response } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/DownloadApiHelperImpl.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/DownloadApiHelperImpl.kt index b0a26f36a..aaf562e09 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/DownloadApiHelperImpl.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/DownloadApiHelperImpl.kt @@ -11,7 +11,7 @@ class DownloadApiHelperImpl @Inject constructor(private val downloadApi: Downloa token: String, offset: Int?, page: Int, - limit: Int + limit: Int, ): Response> = downloadApi.getDownloads(token, offset, page, limit) override suspend fun deleteDownload(token: String, id: String) = diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApi.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApi.kt index 6cd8efd15..fea999d0e 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApi.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApi.kt @@ -14,13 +14,13 @@ interface KodiApi { suspend fun openUrl( @Body body: KodiRequest, @Header("Authorization") auth: String? = null, - @Header("Content-Type") contentType: String = "application/json" + @Header("Content-Type") contentType: String = "application/json", ): Response @POST("jsonrpc") suspend fun getVolume( @Body body: KodiRequest, @Header("Authorization") auth: String? = null, - @Header("Content-Type") contentType: String = "application/json" + @Header("Content-Type") contentType: String = "application/json", ): Response } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApiHelperImpl.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApiHelperImpl.kt index 44f9a3b92..f8374d1ba 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApiHelperImpl.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiApiHelperImpl.kt @@ -11,6 +11,6 @@ class KodiApiHelperImpl(private val kodiApi: KodiApi) : KodiApiHelper { override suspend fun getVolume( request: KodiRequest, - auth: String? + auth: String?, ): Response = kodiApi.getVolume(request, auth) } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiSocket.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiSocket.kt index a487a490b..c51402a51 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiSocket.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/KodiSocket.kt @@ -51,15 +51,15 @@ class KodiSocket @Inject constructor(private val client: OkHttpClient) { super.onClosed(webSocket, code, reason) trySend(WebSocketEvents.ConnectionClosed) } - } + }, ) } } sealed class WebSocketEvents { - object ConnectionOpened : WebSocketEvents() + data object ConnectionOpened : WebSocketEvents() - object ConnectionClosed : WebSocketEvents() + data object ConnectionClosed : WebSocketEvents() data class ConnectionError(val error: String) : WebSocketEvents() diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/StreamingApi.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/StreamingApi.kt index b3f4e1a50..f458ac16c 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/StreamingApi.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/StreamingApi.kt @@ -16,6 +16,6 @@ interface StreamingApi { @GET("streaming/transcode/{id}") suspend fun getStreams( @Header("Authorization") token: String, - @Path("id") id: String + @Path("id") id: String, ): Response } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelper.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelper.kt index 02a29db65..3a23efc71 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelper.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelper.kt @@ -16,7 +16,7 @@ interface TorrentApiHelper { suspend fun addTorrent( token: String, binaryTorrent: RequestBody, - host: String + host: String, ): Response suspend fun addMagnet(token: String, magnet: String, host: String): Response @@ -26,7 +26,7 @@ interface TorrentApiHelper { offset: Int?, page: Int?, limit: Int?, - filter: String? + filter: String?, ): Response> suspend fun selectFiles(token: String, id: String, files: String): Response diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelperImpl.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelperImpl.kt index 774149e47..c8f05437c 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelperImpl.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentApiHelperImpl.kt @@ -19,13 +19,13 @@ class TorrentApiHelperImpl @Inject constructor(private val torrentsApi: Torrents override suspend fun addTorrent( token: String, binaryTorrent: RequestBody, - host: String + host: String, ): Response = torrentsApi.addTorrent(token, binaryTorrent, host) override suspend fun addMagnet( token: String, magnet: String, - host: String + host: String, ): Response = torrentsApi.addMagnet(token, magnet, host) override suspend fun getTorrentsList( @@ -33,7 +33,7 @@ class TorrentApiHelperImpl @Inject constructor(private val torrentsApi: Torrents offset: Int?, page: Int?, limit: Int?, - filter: String? + filter: String?, ): Response> = torrentsApi.getTorrentsList(token, offset, page, limit, filter) override suspend fun selectFiles(token: String, id: String, files: String) = @@ -44,6 +44,6 @@ class TorrentApiHelperImpl @Inject constructor(private val torrentsApi: Torrents override suspend fun getInstantAvailability( token: String, - url: String + url: String, ): Response = torrentsApi.getInstantAvailability(token, url) } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentsApi.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentsApi.kt index 2ecbec419..079a840bc 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentsApi.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/TorrentsApi.kt @@ -29,14 +29,14 @@ interface TorrentsApi { @GET("torrents/info/{id}") suspend fun getTorrentInfo( @Header("Authorization") token: String, - @Path("id") id: String + @Path("id") id: String, ): Response @PUT("torrents/addTorrent") suspend fun addTorrent( @Header("Authorization") token: String, @Body binaryTorrent: RequestBody, - @Query("host") host: String + @Query("host") host: String, ): Response @FormUrlEncoded @@ -44,7 +44,7 @@ interface TorrentsApi { suspend fun addMagnet( @Header("Authorization") token: String, @Field("magnet") magnet: String, - @Field("host") host: String + @Field("host") host: String, ): Response /** @@ -63,7 +63,7 @@ interface TorrentsApi { @Query("offset") offset: Int? = 0, @Query("page") page: Int? = 1, @Query("limit") limit: Int? = 10, - @Query("filter") filter: String? + @Query("filter") filter: String?, ): Response> /** @@ -78,7 +78,7 @@ interface TorrentsApi { suspend fun selectFiles( @Header("Authorization") token: String, @Path("id") id: String, - @Field("files") files: String = "all" + @Field("files") files: String = "all", ): Response /** @@ -102,6 +102,6 @@ interface TorrentsApi { @GET suspend fun getInstantAvailability( @Header("Authorization") token: String, - @Url url: String + @Url url: String, ): Response } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UnrestrictApi.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UnrestrictApi.kt index 4e6987fcd..e8cdb12fe 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UnrestrictApi.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UnrestrictApi.kt @@ -43,7 +43,7 @@ interface UnrestrictApi { @Header("Authorization") token: String, @Field("link") link: String, @Field("password") password: String? = null, - @Field("remote") remote: Int? = null + @Field("remote") remote: Int? = null, ): Response /** @@ -57,7 +57,7 @@ interface UnrestrictApi { @POST("unrestrict/folder") suspend fun getUnrestrictedFolder( @Header("Authorization") token: String, - @Field("link") link: String + @Field("link") link: String, ): Response> /** @@ -70,7 +70,7 @@ interface UnrestrictApi { @PUT("unrestrict/containerFile") suspend fun uploadContainer( @Header("Authorization") token: String, - @Body container: RequestBody + @Body container: RequestBody, ): Response> /** @@ -84,6 +84,6 @@ interface UnrestrictApi { @POST("unrestrict/folder") suspend fun getContainerLinks( @Header("Authorization") token: String, - @Field("link") link: String + @Field("link") link: String, ): Response> } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UnrestrictApiHelper.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UnrestrictApiHelper.kt index c2d71ac91..ac6fd8304 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UnrestrictApiHelper.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UnrestrictApiHelper.kt @@ -10,7 +10,7 @@ interface UnrestrictApiHelper { token: String, link: String, password: String? = null, - remote: Int? = null + remote: Int? = null, ): Response suspend fun getUnrestrictedFolder(token: String, link: String): Response> diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UnrestrictApiHelperImpl.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UnrestrictApiHelperImpl.kt index f50539ad5..41ed4de46 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UnrestrictApiHelperImpl.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/remote/UnrestrictApiHelperImpl.kt @@ -11,17 +11,17 @@ class UnrestrictApiHelperImpl @Inject constructor(private val unrestrictApi: Unr token: String, link: String, password: String?, - remote: Int? + remote: Int?, ): Response = unrestrictApi.getUnrestrictedLink(token, link, password, remote) override suspend fun getUnrestrictedFolder( token: String, - link: String + link: String, ): Response> = unrestrictApi.getUnrestrictedFolder(token, link) override suspend fun uploadContainer( token: String, - container: RequestBody + container: RequestBody, ): Response> = unrestrictApi.uploadContainer(token, container) override suspend fun getContainerLinks(token: String, link: String): Response> = diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/AuthenticationRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/AuthenticationRepository.kt index 91446c8bc..f3bd7f21f 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/AuthenticationRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/AuthenticationRepository.kt @@ -19,7 +19,7 @@ constructor(private val protoStore: ProtoStore, private val apiHelper: AuthApiHe val authResponse = safeApiCall( call = { apiHelper.getAuthentication() }, - errorMessage = "Error Fetching Authentication Info" + errorMessage = "Error Fetching Authentication Info", ) return authResponse @@ -30,7 +30,7 @@ constructor(private val protoStore: ProtoStore, private val apiHelper: AuthApiHe val secretResponse = safeApiCall( call = { apiHelper.getSecrets(deviceCode = code) }, - errorMessage = "Error Fetching Secrets" + errorMessage = "Error Fetching Secrets", ) return secretResponse @@ -44,19 +44,19 @@ constructor(private val protoStore: ProtoStore, private val apiHelper: AuthApiHe apiHelper.getToken( clientId = clientId, clientSecret = clientSecret, - code = code + code = code, ) }, - errorMessage = "Error Fetching Token" + errorMessage = "Error Fetching Token", ) return tokenResponse } - suspend fun getTokenOrError( + private suspend fun getTokenOrError( clientId: String, clientSecret: String, - code: String + code: String, ): EitherResult { val tokenResponse = @@ -65,10 +65,10 @@ constructor(private val protoStore: ProtoStore, private val apiHelper: AuthApiHe apiHelper.getToken( clientId = clientId, clientSecret = clientSecret, - code = code + code = code, ) }, - errorMessage = "Error Fetching Token" + errorMessage = "Error Fetching Token", ) return tokenResponse @@ -85,17 +85,12 @@ constructor(private val protoStore: ProtoStore, private val apiHelper: AuthApiHe suspend fun refreshToken(clientId: String, clientSecret: String, refreshToken: String): Token? = getToken(clientId, clientSecret, refreshToken) - suspend fun refreshToken( - credentials: com.github.livingwithhippos.unchained.data.local.Credentials.CurrentCredential - ): Token? = - refreshToken(credentials.clientId!!, credentials.clientSecret!!, credentials.refreshToken!!) - suspend fun refreshTokenWithError( credentials: com.github.livingwithhippos.unchained.data.local.Credentials.CurrentCredential ): EitherResult = getTokenOrError( credentials.clientId!!, credentials.clientSecret!!, - credentials.refreshToken!! + credentials.refreshToken!!, ) } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/BaseRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/BaseRepository.kt index 43a7101cd..5a7672c5d 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/BaseRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/BaseRepository.kt @@ -48,7 +48,7 @@ open class BaseRepository(private val protoStore: ProtoStore) { private suspend fun safeApiResult( call: suspend () -> Response, - errorMessage: String + errorMessage: String, ): NetworkResponse { try { val response: Response = call.invoke() @@ -68,7 +68,7 @@ open class BaseRepository(private val protoStore: ProtoStore) { suspend fun eitherApiResult( call: suspend () -> Response, - errorMessage: String + errorMessage: String, ): EitherResult = withContext(Dispatchers.IO) { val response: Response = diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/CustomDownloadRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/CustomDownloadRepository.kt index b5c00c808..226b15213 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/CustomDownloadRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/CustomDownloadRepository.kt @@ -27,7 +27,7 @@ constructor(protoStore: ProtoStore, private val customDownloadHelper: CustomDown url: String, fileName: String, cacheDir: File, - suffix: String? = null + suffix: String? = null, ): Flow = channelFlow { if (url.isWebUrl()) { // todo: use the FileWriter and Downloader helper classes @@ -92,14 +92,14 @@ constructor(protoStore: ProtoStore, private val customDownloadHelper: CustomDown return eitherApiResult( call = { customDownloadHelper.getPluginsRepository(link) }, - errorMessage = "Error Fetching plugins repository" + errorMessage = "Error Fetching plugins repository", ) } suspend fun downloadPlugin(link: String): EitherResult { return eitherApiResult( call = { customDownloadHelper.getPlugin(link) }, - errorMessage = "Error fetching plugin" + errorMessage = "Error fetching plugin", ) } @@ -112,7 +112,7 @@ constructor(protoStore: ProtoStore, private val customDownloadHelper: CustomDown suspend fun downloadAsString(url: String): EitherResult { return eitherApiResult( call = { customDownloadHelper.getAsString(url) }, - errorMessage = "Error fetching url as a string" + errorMessage = "Error fetching url as a string", ) } } @@ -120,9 +120,9 @@ constructor(protoStore: ProtoStore, private val customDownloadHelper: CustomDown sealed class DownloadResult { data class Progress(val percent: Int) : DownloadResult() - object WrongURL : DownloadResult() + data object WrongURL : DownloadResult() data class End(val fileName: String) : DownloadResult() - object Failure : DownloadResult() + data object Failure : DownloadResult() } 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 8a8830337..8724a349e 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 @@ -68,17 +68,13 @@ constructor(private val repositoryDataDao: RepositoryDataDao) { plugin = plugin.id, version = it.plugin, engine = it.engine, - link = it.link + link = it.link, ) } ) } } - suspend fun removeRepository(repository: Repository) { - repositoryDataDao.delete(repository) - } - suspend fun removeRepository(link: String) { repositoryDataDao.delete(Repository(link)) } @@ -117,4 +113,11 @@ constructor(private val repositoryDataDao: RepositoryDataDao) { } return pluginsMap } + + suspend fun getEnabledPlugins(): Map> = + repositoryDataDao.getEnabledPlugins() + + suspend fun enablePlugin(name: String, enabled: Boolean) { + repositoryDataDao.enablePlugin(name, enabled) + } } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/DownloadRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/DownloadRepository.kt index 20647efb8..421a10742 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/DownloadRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/DownloadRepository.kt @@ -7,7 +7,7 @@ import javax.inject.Inject class DownloadRepository @Inject -constructor(private val protoStore: ProtoStore, private val downloadApiHelper: DownloadApiHelper) : +constructor(protoStore: ProtoStore, private val downloadApiHelper: DownloadApiHelper) : BaseRepository(protoStore) { suspend fun getDownloads(offset: Int?, page: Int = 1, limit: Int = 50): List { @@ -16,7 +16,7 @@ constructor(private val protoStore: ProtoStore, private val downloadApiHelper: D call = { downloadApiHelper.getDownloads("Bearer ${getToken()}", offset, page, limit) }, - errorMessage = "Error Fetching Downloads list or list empty" + errorMessage = "Error Fetching Downloads list or list empty", ) return downloadResponse ?: emptyList() @@ -29,7 +29,7 @@ constructor(private val protoStore: ProtoStore, private val downloadApiHelper: D call = { downloadApiHelper.deleteDownload(token = "Bearer ${getToken()}", id = id) }, - errorMessage = "Error deleting download" + errorMessage = "Error deleting download", ) return response diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/HostsRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/HostsRepository.kt index 97381bdd9..1ba81823e 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/HostsRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/HostsRepository.kt @@ -2,7 +2,6 @@ package com.github.livingwithhippos.unchained.data.repository import com.github.livingwithhippos.unchained.data.local.HostRegexDao import com.github.livingwithhippos.unchained.data.local.ProtoStore -import com.github.livingwithhippos.unchained.data.model.Host import com.github.livingwithhippos.unchained.data.model.HostRegex import com.github.livingwithhippos.unchained.data.model.REGEX_TYPE_FOLDER import com.github.livingwithhippos.unchained.data.model.REGEX_TYPE_HOST @@ -14,22 +13,11 @@ import javax.inject.Inject class HostsRepository @Inject constructor( - private val protoStore: ProtoStore, + protoStore: ProtoStore, private val hostsApiHelper: HostsApiHelper, - private val hostRegexDao: HostRegexDao + private val hostRegexDao: HostRegexDao, ) : BaseRepository(protoStore) { - suspend fun getHostsStatus(): Host? { - - val hostResponse = - safeApiCall( - call = { hostsApiHelper.getHostsStatus("Bearer ${getToken()}") }, - errorMessage = "Error Fetching Streaming Info" - ) - - return hostResponse - } - /** * Gets the regexps to filter supported hosts from the network. Custom regexps are also added * here. @@ -41,7 +29,7 @@ constructor( val hostResponse = safeApiCall( call = { hostsApiHelper.getHostsRegex() }, - errorMessage = "Error Fetching Hosts Regex" + errorMessage = "Error Fetching Hosts Regex", ) val list = mutableListOf() // add the regexps from the network @@ -62,7 +50,7 @@ constructor( val hostResponse = safeApiCall( call = { hostsApiHelper.getHostsFoldersRegex() }, - errorMessage = "Error Fetching Hosts Folders Regex" + errorMessage = "Error Fetching Hosts Folders Regex", ) val list = mutableListOf() // add the regexps from the network @@ -155,9 +143,10 @@ constructor( originalRegex .trim() .replace("/(http|https):\\/\\/", "^https?:\\/\\/", ignoreCase = true) - if (newRegex[newRegex.lastIndex] == "/"[0]) - // substring endIndex is not included - newRegex = newRegex.substring(0, newRegex.lastIndex) + "$" + if (newRegex[newRegex.lastIndex] == "/"[0]) { + // substring endIndex is not included + newRegex = newRegex.substring(0, newRegex.lastIndex) + "$" + } try { Pattern.compile(newRegex) } catch (e: PatternSyntaxException) { @@ -177,7 +166,7 @@ constructor( "^(https?://)?(www?\\d?\\.)?katfile\\.com/\\w+/[^\\s]+\$", "^(https?://)?(www?\\d?\\.)?clicknupload\\.cc/\\w+/[^\\s]+\$", "^(https?://)?(www?\\d?\\.)?fastclick\\.to/\\w+/[^\\s]+\$", - "^(https?://)?(www?\\d?\\.)?drop\\.download/\\w+/[^\\s]+\$" + "^(https?://)?(www?\\d?\\.)?drop\\.download/\\w+/[^\\s]+\$", ) // if any of the converted folder regexps are wrong, we can add these to the db manually 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 new file mode 100644 index 000000000..e3a559e7c --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/JackettRepository.kt @@ -0,0 +1,253 @@ +package com.github.livingwithhippos.unchained.data.repository + +import android.net.Uri +import com.fasterxml.jackson.module.kotlin.readValue +import com.github.livingwithhippos.unchained.data.model.jackett.Indexers +import com.github.livingwithhippos.unchained.data.model.torznab.Capabilities +import com.github.livingwithhippos.unchained.data.model.torznab.SearchRSS +import com.github.livingwithhippos.unchained.di.ClassicClient +import com.github.livingwithhippos.unchained.utilities.EitherResult +import com.github.livingwithhippos.unchained.utilities.xml.xmlMapper +import java.io.IOException +import java.net.URISyntaxException +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber + +class JackettRepository @Inject constructor(@ClassicClient private val client: OkHttpClient) { + + private fun getBasicBuilder( + baseUrl: String, + port: Int = 9117, + apiKey: String, + indexersFilter: String = "all", + useSecureHttp: Boolean = false, + ): Uri.Builder? { + var existingUri: Uri = + try { + Uri.parse("$baseUrl:$port") + } catch (ex: Exception) { + Timber.e(ex, "Error parsing url: $baseUrl:$port") + return null + } + + if ( + !(existingUri.scheme.equals("http", ignoreCase = true) || + existingUri.scheme.equals("https", ignoreCase = true)) + ) { + existingUri = Uri.parse("${if (useSecureHttp) "https" else "http"}://$baseUrl:$port") + } + 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, + indexer: String = "all", + apiKey: String, + query: String, + categories: String = "", + attributes: String? = null, + extended: Boolean? = null, + offset: Int? = null, + limit: Int? = null, + mediaType: JackettMediaType? = null, + genre: JackettGenre? = null, + year: String? = null, + season: String? = null, + episodes: String? = null, + imdb: String? = null, + 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) + + 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) + + builder.appendQueryParameter("q", query) + + val request = Request.Builder().url(builder.build().toString()).build() + + 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") + } + + return@withContext EitherResult.Failure(IOException("Unexpected search failure")) + } + } + + suspend fun getCapabilities( + baseUrl: String, + port: Int = 9117, + apiKey: String, + indexer: String = "all", + ): EitherResult = + withContext(Dispatchers.IO) { + val builder = + getBasicBuilder(baseUrl, port, apiKey, indexer) + ?: return@withContext EitherResult.Failure( + IllegalArgumentException("Impossible to parse url") + ) + builder.appendQueryParameter("t", "caps") + + val request = Request.Builder().url(builder.build().toString()).build() + + 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 capabilities = xmlMapper.readValue(body) + return@withContext EitherResult.Success(capabilities) + } catch (ex: Exception) { + Timber.e(ex, "Error parsing Capabilities response") + } + + return@withContext EitherResult.Failure( + IOException("Unexpected capabilities failure") + ) + } + } + + suspend fun getValidIndexers( + baseUrl: String, + port: Int = 9117, + apiKey: String, + configured: Boolean = true, + ): EitherResult = + withContext(Dispatchers.IO) { + try { + val builder = + getBasicBuilder( + baseUrl, + port, + apiKey, + indexersFilter = "!status:failing,test:passed", + ) + ?: return@withContext EitherResult.Failure( + IllegalArgumentException("Impossible to parse url") + ) + builder.appendQueryParameter("t", "indexers") + + if (configured) builder.appendQueryParameter("configured", "true") + else builder.appendQueryParameter("configured", "false") + + val request = Request.Builder().url(builder.build().toString()).build() + + 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 indexers = xmlMapper.readValue(body) + return@withContext EitherResult.Success(indexers) + } catch (ex: Exception) { + Timber.e(ex, "Error parsing indexers response") + } + + return@withContext EitherResult.Failure( + IOException("Unexpected indexers failure") + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error getting indexers") + return@withContext EitherResult.Failure(ex) + } + } +} + +enum class JackettMediaType(val value: String) { + ALL("search"), + MOVIE("movie"), + TV("tvsearch"), + MUSIC("music"), + BOOK("book"), +} + +enum class JackettGenre(val value: String) { + COMEDY("comedy"), + HORROR("horror"), +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiDeviceRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiDeviceRepository.kt index 6ca642871..ef1daa5db 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiDeviceRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiDeviceRepository.kt @@ -14,6 +14,8 @@ class KodiDeviceRepository @Inject constructor(private val kodiDeviceDao: KodiDe return kodiDeviceDao.getAllDevices() } + suspend fun deleteAll() = kodiDeviceDao.deleteAll() + suspend fun add(device: KodiDevice): Long { if (device.isDefault) kodiDeviceDao.resetDefaults() return kodiDeviceDao.insert(device) @@ -52,7 +54,7 @@ class KodiDeviceRepository @Inject constructor(private val kodiDeviceDao: KodiDe device.username, device.password, if (device.isDefault) 1 else 0, - oldDeviceName + oldDeviceName, ) } } 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 d920cbfbd..58440fe98 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 @@ -11,6 +11,7 @@ import com.github.livingwithhippos.unchained.data.remote.KodiApi import com.github.livingwithhippos.unchained.data.remote.KodiApiHelper import com.github.livingwithhippos.unchained.data.remote.KodiApiHelperImpl import com.github.livingwithhippos.unchained.di.ClassicClient +import com.github.livingwithhippos.unchained.utilities.addHttpScheme import javax.inject.Inject import okhttp3.OkHttpClient import retrofit2.Retrofit @@ -19,7 +20,7 @@ import timber.log.Timber class KodiRepository @Inject -constructor(private val protoStore: ProtoStore, @ClassicClient private val client: OkHttpClient) : +constructor(protoStore: ProtoStore, @ClassicClient private val client: OkHttpClient) : BaseRepository(protoStore) { // todo: conflict with other Retrofit with qualifiers? @@ -36,18 +37,17 @@ constructor(private val protoStore: ProtoStore, @ClassicClient private val clien } private fun provideApiHelper(baseUrl: String): KodiApiHelper { - val apiHelper = KodiApiHelperImpl(provideApi(baseUrl)) - return apiHelper + return KodiApiHelperImpl(provideApi(baseUrl)) } suspend fun getVolume( baseUrl: String, port: Int, username: String? = null, - password: String? = null + password: String? = null, ): KodiGenericResponse? { try { - val kodiApiHelper: KodiApiHelper = provideApiHelper("http://$baseUrl:$port/") + val kodiApiHelper: KodiApiHelper = provideApiHelper("${addHttpScheme(baseUrl)}:$port/") val kodiResponse = safeApiCall( call = { @@ -55,12 +55,12 @@ constructor(private val protoStore: ProtoStore, @ClassicClient private val clien request = KodiRequest( method = "Application.GetProperties", - params = KodiParams(properties = listOf("volume")) + params = KodiParams(properties = listOf("volume")), ), - auth = encodeAuthentication(username, password) + auth = encodeAuthentication(username, password), ) }, - errorMessage = "Error getting Kodi volume" + errorMessage = "Error getting Kodi volume", ) return kodiResponse @@ -75,11 +75,14 @@ constructor(private val protoStore: ProtoStore, @ClassicClient private val clien port: Int, url: String, username: String? = null, - password: String? = null + password: String? = null, ): KodiResponse? { try { - val kodiApiHelper: KodiApiHelper = provideApiHelper("http://$baseUrl:$port/") + val kodiApiHelper: KodiApiHelper = + if (baseUrl.startsWith("http", ignoreCase = true)) + provideApiHelper("$baseUrl:$port/") + else provideApiHelper("http://$baseUrl:$port/") val kodiResponse = safeApiCall( @@ -88,12 +91,12 @@ constructor(private val protoStore: ProtoStore, @ClassicClient private val clien request = KodiRequest( method = "Player.Open", - params = KodiParams(item = KodiItem(fileUrl = url)) + params = KodiParams(item = KodiItem(fileUrl = url)), ), - auth = encodeAuthentication(username, password) + auth = encodeAuthentication(username, password), ) }, - errorMessage = "Error Sending url to Kodi" + errorMessage = "Error Sending url to Kodi", ) return kodiResponse 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 47eb413b1..2512a991b 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 @@ -25,7 +25,7 @@ class PluginRepository @Inject constructor() { suspend fun getPlugins( context: Context, - manuallyInstalledOnly: Boolean = false + manuallyInstalledOnly: Boolean = false, ): Pair, Int> = withContext(Dispatchers.IO) { val pluginFiles = mutableListOf() @@ -136,11 +136,11 @@ class PluginRepository @Inject constructor() { LocalPlugins(pluginsData, errors) } - suspend fun removePlugin( + private suspend fun removePlugin( context: Context, repository: String, author: String?, - name: String + name: String, ): Boolean = withContext(Dispatchers.IO) { val pluginFolder = context.getDir("plugins", Context.MODE_PRIVATE) @@ -247,7 +247,7 @@ class PluginRepository @Inject constructor() { return@withContext true } - suspend fun readPassedPlugin(context: Context, data: Uri): Plugin? = + private suspend fun readPassedPlugin(context: Context, data: Uri): Plugin? = withContext(Dispatchers.IO) { val filename = data.path?.split("/")?.last() if (filename != null) { @@ -258,7 +258,7 @@ class PluginRepository @Inject constructor() { return@withContext getPluginFromJSON(json) } } catch (exception: Exception) { - Timber.e("Error adding the plugin $filename: ${exception.message}") + Timber.e(exception, "Error adding the plugin $filename: ${exception.message}") } } @@ -281,7 +281,7 @@ class PluginRepository @Inject constructor() { suspend fun savePlugin( context: Context, plugin: Plugin, - repositoryURL: String? + repositoryURL: String?, ): InstallResult = withContext(Dispatchers.IO) { // we use the repo link hash as folder name for all the plugins from that repo @@ -343,11 +343,11 @@ class PluginRepository @Inject constructor() { } sealed class InstallResult { - object Installed : InstallResult() + data object Installed : InstallResult() data class Error(val exception: Exception) : InstallResult() - object Incompatible : InstallResult() + data object Incompatible : InstallResult() } data class LocalPlugins(val pluginsData: Map>, val errors: Int) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/RemoteDeviceRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/RemoteDeviceRepository.kt new file mode 100644 index 000000000..a8c456994 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/RemoteDeviceRepository.kt @@ -0,0 +1,61 @@ +package com.github.livingwithhippos.unchained.data.repository + +import com.github.livingwithhippos.unchained.data.local.RemoteDevice +import com.github.livingwithhippos.unchained.data.local.RemoteDeviceDao +import com.github.livingwithhippos.unchained.data.local.RemoteService +import com.github.livingwithhippos.unchained.data.local.RemoteServiceType +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class RemoteDeviceRepository @Inject constructor(private val remoteDeviceDao: RemoteDeviceDao) { + suspend fun getAllDevices() = remoteDeviceDao.getAllDevices() + + suspend fun deleteAll() = remoteDeviceDao.deleteAll() + + suspend fun getDeviceServices(deviceId: Int): List = + remoteDeviceDao.getDeviceServices(deviceId) + + suspend fun getDevicesAndServices(): Map> = + remoteDeviceDao.getDevicesAndServices() + + suspend fun getMediaPlayerDevicesAndServices(): Map> = + remoteDeviceDao.getMediaPlayerDevicesAndServices( + types = listOf(RemoteServiceType.KODI.value, RemoteServiceType.VLC.value) + ) + + suspend fun getMediaPlayerDevicesAndServicesFlow(): + Flow>> = + remoteDeviceDao.getMediaPlayerDevicesAndServicesFlow( + types = listOf(RemoteServiceType.KODI.value, RemoteServiceType.VLC.value) + ) + + suspend fun insertDevice(device: RemoteDevice): Long = remoteDeviceDao.insertDevice(device) + + suspend fun upsertDevice(device: RemoteDevice): Long = remoteDeviceDao.upsertDevice(device) + + suspend fun insertService(service: RemoteService): Long = remoteDeviceDao.insertService(service) + + suspend fun getDeviceIDByRow(rowId: Long): Int? = remoteDeviceDao.getDeviceIDByRow(rowId) + + suspend fun getServiceIDByRow(rowId: Long): Int? = remoteDeviceDao.getServiceIDByRow(rowId) + + suspend fun setDefaultDevice(deviceId: Int) = remoteDeviceDao.setDefaultDevice(deviceId) + + suspend fun setDefaultDeviceService(deviceId: Int, serviceId: Int) = + remoteDeviceDao.setDefaultDeviceService(deviceId, serviceId) + + suspend fun getService(serviceID: Int): RemoteService? = remoteDeviceDao.getService(serviceID) + + suspend fun getDevice(deviceID: Int): RemoteDevice? = remoteDeviceDao.getDevice(deviceID) + + suspend fun deleteService(service: RemoteService) = remoteDeviceDao.deleteService(service) + + suspend fun deleteDevice(device: RemoteDevice) = remoteDeviceDao.deleteDevice(device) + + suspend fun deleteDevice(deviceID: Int) = remoteDeviceDao.deleteDevice(deviceID) + + suspend fun deleteAllDeviceServices(deviceID: Int) = + remoteDeviceDao.removeDeviceServices(deviceID) + + suspend fun getDefaultDeviceWithServices() = remoteDeviceDao.getDefaultDeviceWithServices() +} 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 new file mode 100644 index 000000000..d836d260b --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/RemoteRepository.kt @@ -0,0 +1,50 @@ +package com.github.livingwithhippos.unchained.data.repository + +import com.github.livingwithhippos.unchained.di.ClassicClient +import com.github.livingwithhippos.unchained.utilities.EitherResult +import com.github.livingwithhippos.unchained.utilities.addHttpScheme +import java.io.IOException +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber + +class RemoteRepository @Inject constructor(@ClassicClient private val client: OkHttpClient) { + // todo: add https://mpv.io/manual/stable/#json-ipc + + suspend fun openUrl( + baseUrl: String, + port: Int = 9090, + url: String, + username: String? = null, + password: String? = null, + ): EitherResult = + withContext(Dispatchers.IO) { + // https://wiki.videolan.org/Documentation:Modules/http_intf/#VLC_2.0.0_and_later + // needs a password or it won't work: + // vlc --http-host 0.0.0.0 --http-port 9090 --http-password pass + // also on some linux distro there may be a bug crashing the app 'glconv_vaapi_x11 gl + // error: vaInitialize: unknown libva error' + // workaround with export LIBVA_DRIVER_NAME=nvidia + val credential = okhttp3.Credentials.basic(username ?: "", password ?: "") + val request = + Request.Builder() + .url( + "${addHttpScheme(baseUrl)}:$port/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/StreamingRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/StreamingRepository.kt index 524d9ae0c..e39cab369 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/StreamingRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/StreamingRepository.kt @@ -7,17 +7,15 @@ import javax.inject.Inject class StreamingRepository @Inject -constructor( - private val protoStore: ProtoStore, - private val streamingApiHelper: StreamingApiHelper -) : BaseRepository(protoStore) { +constructor(protoStore: ProtoStore, private val streamingApiHelper: StreamingApiHelper) : + BaseRepository(protoStore) { suspend fun getStreams(id: String): Stream? { val streamResponse = safeApiCall( call = { streamingApiHelper.getStreams("Bearer ${getToken()}", id) }, - errorMessage = "Error Fetching Streaming Info" + errorMessage = "Error Fetching Streaming Info", ) return streamResponse diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/TorrentsRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/TorrentsRepository.kt index 6dbe9820e..f34b98221 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/TorrentsRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/TorrentsRepository.kt @@ -16,7 +16,7 @@ import timber.log.Timber class TorrentsRepository @Inject -constructor(private val protoStore: ProtoStore, private val torrentApiHelper: TorrentApiHelper) : +constructor(protoStore: ProtoStore, private val torrentApiHelper: TorrentApiHelper) : BaseRepository(protoStore) { suspend fun getAvailableHosts(): List? { @@ -24,7 +24,7 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To val hostResponse: List? = safeApiCall( call = { torrentApiHelper.getAvailableHosts(token = "Bearer $token") }, - errorMessage = "Error Retrieving Available Hosts" + errorMessage = "Error Retrieving Available Hosts", ) return hostResponse @@ -35,7 +35,7 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To val torrentResponse: TorrentItem? = safeApiCall( call = { torrentApiHelper.getTorrentInfo(token = "Bearer $token", id = id) }, - errorMessage = "Error Retrieving Torrent Info" + errorMessage = "Error Retrieving Torrent Info", ) return torrentResponse @@ -43,7 +43,7 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To suspend fun addTorrent( binaryTorrent: ByteArray, - host: String + host: String, ): EitherResult { val token = getToken() @@ -51,7 +51,7 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To binaryTorrent.toRequestBody( "application/octet-stream".toMediaTypeOrNull(), 0, - binaryTorrent.size + binaryTorrent.size, ) val addTorrentResponse = @@ -60,10 +60,10 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To torrentApiHelper.addTorrent( token = "Bearer $token", binaryTorrent = requestBody, - host = host + host = host, ) }, - errorMessage = "Error Uploading Torrent" + errorMessage = "Error Uploading Torrent", ) return addTorrentResponse @@ -71,7 +71,7 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To suspend fun addMagnet( magnet: String, - host: String + host: String, ): EitherResult { val token = getToken() val torrentResponse = @@ -80,10 +80,10 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To torrentApiHelper.addMagnet( token = "Bearer $token", magnet = magnet, - host = host + host = host, ) }, - errorMessage = "Error Uploading Torrent From Magnet" + errorMessage = "Error Uploading Torrent From Magnet", ) return torrentResponse @@ -93,7 +93,7 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To offset: Int? = null, page: Int? = 1, limit: Int? = 50, - filter: String? = null + filter: String? = null, ): List { val token = getToken() @@ -105,10 +105,10 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To offset = offset, page = page, limit = limit, - filter = filter + filter = filter, ) }, - errorMessage = "Error retrieving the torrents List, or list empty" + errorMessage = "Error retrieving the torrents List, or list empty", ) return torrentsResponse ?: emptyList() @@ -116,7 +116,7 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To suspend fun selectFiles( id: String, - files: String = "all" + files: String = "all", ): EitherResult { val token = getToken() @@ -127,7 +127,7 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To call = { torrentApiHelper.selectFiles(token = "Bearer $token", id = id, files = files) }, - errorMessage = "Error Selecting Torrent Files" + errorMessage = "Error Selecting Torrent Files", ) return response @@ -139,7 +139,7 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To val response = eitherApiResult( call = { torrentApiHelper.deleteTorrent(token = "Bearer $token", id = id) }, - errorMessage = "Error deleting Torrent" + errorMessage = "Error deleting Torrent", ) return response @@ -155,7 +155,7 @@ constructor(private val protoStore: ProtoStore, private val torrentApiHelper: To call = { torrentApiHelper.getInstantAvailability(token = "Bearer $token", url = url) }, - errorMessage = "Error getting cached torrent files" + errorMessage = "Error getting cached torrent files", ) return response diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UnrestrictRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UnrestrictRepository.kt index b53ad2909..0bcf88518 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UnrestrictRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UnrestrictRepository.kt @@ -13,15 +13,13 @@ import okhttp3.RequestBody.Companion.toRequestBody class UnrestrictRepository @Inject -constructor( - private val protoStore: ProtoStore, - private val unrestrictApiHelper: UnrestrictApiHelper -) : BaseRepository(protoStore) { +constructor(protoStore: ProtoStore, private val unrestrictApiHelper: UnrestrictApiHelper) : + BaseRepository(protoStore) { suspend fun getEitherUnrestrictedLink( link: String, password: String? = null, - remote: Int? = null + remote: Int? = null, ): EitherResult { val token = getToken() @@ -32,10 +30,10 @@ constructor( token = "Bearer $token", link = link, password = password, - remote = remote + remote = remote, ) }, - errorMessage = "Error Fetching Unrestricted Link Info" + errorMessage = "Error Fetching Unrestricted Link Info", ) return linkResponse @@ -45,7 +43,7 @@ constructor( linksList: List, password: String? = null, remote: Int? = null, - callDelay: Long = 100 + callDelay: Long = 100, ): List> { val unrestrictedLinks = mutableListOf>() @@ -57,28 +55,6 @@ constructor( return unrestrictedLinks } - suspend fun getEitherUnrestrictedFolder( - link: String, - password: String? = null, - remote: Int? = null - ): List> { - val token = getToken() - - val folderResponse: EitherResult> = - eitherApiResult( - call = { - unrestrictApiHelper.getUnrestrictedFolder(token = "Bearer $token", link = link) - }, - errorMessage = "Error Fetching Unrestricted Folders Info" - ) - - return when (folderResponse) { - is EitherResult.Success -> - getUnrestrictedLinkList(folderResponse.success, password, remote) - is EitherResult.Failure -> listOf(EitherResult.Failure(folderResponse.failure)) - } - } - suspend fun getEitherFolderLinks( link: String ): EitherResult> { @@ -89,7 +65,7 @@ constructor( call = { unrestrictApiHelper.getUnrestrictedFolder(token = "Bearer $token", link = link) }, - errorMessage = "Error Fetching Unrestricted Folders Info" + errorMessage = "Error Fetching Unrestricted Folders Info", ) return folderResponse @@ -104,7 +80,7 @@ constructor( container.toRequestBody( "application/octet-stream".toMediaTypeOrNull(), 0, - container.size + container.size, ) val uploadResponse = @@ -112,10 +88,10 @@ constructor( call = { unrestrictApiHelper.uploadContainer( token = "Bearer $token", - container = requestBody + container = requestBody, ) }, - errorMessage = "Error Uploading Container" + errorMessage = "Error Uploading Container", ) return uploadResponse @@ -129,7 +105,7 @@ constructor( call = { unrestrictApiHelper.getContainerLinks(token = "Bearer $token", link = link) }, - errorMessage = "Error getting container files" + errorMessage = "Error getting container files", ) return containerResponse diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UpdateRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UpdateRepository.kt index c7e92e440..bd228c6f2 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UpdateRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UpdateRepository.kt @@ -8,7 +8,7 @@ import javax.inject.Inject class UpdateRepository @Inject -constructor(private val protoStore: ProtoStore, private val updateApiHelper: UpdateApiHelper) : +constructor(protoStore: ProtoStore, private val updateApiHelper: UpdateApiHelper) : BaseRepository(protoStore) { suspend fun getUpdates(url: String = SIGNATURE.URL): Updates? { @@ -16,7 +16,7 @@ constructor(private val protoStore: ProtoStore, private val updateApiHelper: Upd val response = safeApiCall( call = { updateApiHelper.getUpdates(url) }, - errorMessage = "Error getting updates" + errorMessage = "Error getting updates", ) return response diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UserRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UserRepository.kt index 8787903e4..9b79efd24 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UserRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/UserRepository.kt @@ -9,7 +9,7 @@ import javax.inject.Inject class UserRepository @Inject -constructor(private val protoStore: ProtoStore, private val userApiHelper: UserApiHelper) : +constructor(protoStore: ProtoStore, private val userApiHelper: UserApiHelper) : BaseRepository(protoStore) { suspend fun getUserInfo(token: String): User? { @@ -17,7 +17,7 @@ constructor(private val protoStore: ProtoStore, private val userApiHelper: UserA val userResponse = safeApiCall( call = { userApiHelper.getUser("Bearer $token") }, - errorMessage = "Error Fetching User Info" + errorMessage = "Error Fetching User Info", ) return userResponse @@ -28,7 +28,7 @@ constructor(private val protoStore: ProtoStore, private val userApiHelper: UserA val userResponse = eitherApiResult( call = { userApiHelper.getUser("Bearer $token") }, - errorMessage = "Error Fetching User Info" + errorMessage = "Error Fetching User Info", ) return userResponse diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/VariousApiRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/VariousApiRepository.kt index 9d9ab88eb..54155b242 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/VariousApiRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/VariousApiRepository.kt @@ -6,7 +6,7 @@ import javax.inject.Inject class VariousApiRepository @Inject -constructor(private val protoStore: ProtoStore, private val variousApiHelper: VariousApiHelper) : +constructor(protoStore: ProtoStore, private val variousApiHelper: VariousApiHelper) : BaseRepository(protoStore) { suspend fun disableToken(): Unit? { @@ -14,7 +14,7 @@ constructor(private val protoStore: ProtoStore, private val variousApiHelper: Va val response = safeApiCall( call = { variousApiHelper.disableToken(token = "Bearer ${getToken()}") }, - errorMessage = "Error disabling token" + errorMessage = "Error disabling token", ) return response 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 b0f7d5a6f..77b44f7e6 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 @@ -5,13 +5,12 @@ import android.app.Notification import android.app.PendingIntent import android.content.Intent import android.content.SharedPreferences -import android.content.pm.ServiceInfo +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.os.Binder import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.core.app.ServiceCompat import androidx.core.app.TaskStackBuilder import androidx.lifecycle.LifecycleService import androidx.lifecycle.MutableLiveData @@ -124,16 +123,11 @@ class ForegroundTorrentService : LifecycleService() { if (shouldVibrate && finishedTorrents.isNotEmpty()) applicationContext.vibrate() } - ServiceCompat.startForeground( - this, - SUMMARY_ID, - summaryBuilder.build(), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - } else { - 0 - } - ) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + startForeground(SUMMARY_ID, summaryBuilder.build()) + } else { + startForeground(SUMMARY_ID, summaryBuilder.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } preferences.registerOnSharedPreferenceChangeListener(preferenceListener) } @@ -176,11 +170,7 @@ class ForegroundTorrentService : LifecycleService() { torrentBuilder .setProgress(100, torrent.progress.toInt(), false) .setContentTitle( - getString( - R.string.torrent_in_progress_format, - torrent.progress.toInt(), - speedMBs - ) + getString(R.string.torrent_in_progress_format, torrent.progress, speedMBs) ) .setOngoing(true) } else { @@ -208,7 +198,7 @@ class ForegroundTorrentService : LifecycleService() { torrent.id.hashCode(), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - else PendingIntent.FLAG_UPDATE_CURRENT + else PendingIntent.FLAG_UPDATE_CURRENT, ) } @@ -243,7 +233,7 @@ class ForegroundTorrentService : LifecycleService() { item.id.hashCode(), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - else PendingIntent.FLAG_UPDATE_CURRENT + else PendingIntent.FLAG_UPDATE_CURRENT, ) } 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 d39af0e3d..33fa80d24 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 @@ -76,7 +76,7 @@ object ApiFactory { ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) .allEnabledTlsVersions() .allEnabledCipherSuites() - .build() + .build(), ) ) // logs all the calls, removed in the release channel @@ -93,7 +93,7 @@ object ApiFactory { ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) .allEnabledTlsVersions() .allEnabledCipherSuites() - .build() + .build(), ) ) // avoid issues with empty bodies on delete/put and 20x return codes @@ -127,7 +127,7 @@ object ApiFactory { ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) .allEnabledTlsVersions() .allEnabledCipherSuites() - .build() + .build(), ) ) // logs all the calls, removed in the release channel @@ -144,7 +144,7 @@ object ApiFactory { ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) .allEnabledTlsVersions() .allEnabledCipherSuites() - .build() + .build(), ) ) .addInterceptor(EmptyBodyInterceptor) @@ -157,7 +157,7 @@ object ApiFactory { .url("https://dns.google/dns-query".toHttpUrl()) .bootstrapDnsHosts( InetAddress.getByName("8.8.8.8"), - InetAddress.getByName("8.8.4.4") + InetAddress.getByName("8.8.4.4"), ) .build() @@ -307,6 +307,6 @@ object ApiFactory { fun provideParser( preferences: SharedPreferences, @ClassicClient classicClient: OkHttpClient, - @DOHClient dohClient: OkHttpClient + @DOHClient dohClient: OkHttpClient, ): Parser = Parser(preferences, classicClient, dohClient) } 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 0d780433a..78d0d5450 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 @@ -6,6 +6,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.github.livingwithhippos.unchained.data.local.HostRegexDao import com.github.livingwithhippos.unchained.data.local.KodiDeviceDao +import com.github.livingwithhippos.unchained.data.local.RemoteDeviceDao import com.github.livingwithhippos.unchained.data.local.RepositoryDataDao import com.github.livingwithhippos.unchained.data.local.UnchaineDB import com.github.livingwithhippos.unchained.data.model.REGEX_TYPE_HOST @@ -39,6 +40,11 @@ object DatabaseModule { return database.kodiDeviceDao() } + @Provides + fun provideRemoteDeviceDao(database: UnchaineDB): RemoteDeviceDao { + return database.pluginRemoteDeviceDao() + } + @Provides fun providePluginRepositoryDao(database: UnchaineDB): RepositoryDataDao { return database.pluginRepositoryDao() @@ -46,8 +52,8 @@ object DatabaseModule { private val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL( + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( "CREATE TABLE `host_regex` (`regex` TEXT NOT NULL, " + "PRIMARY KEY(`regex`))" ) } @@ -55,8 +61,8 @@ object DatabaseModule { private val MIGRATION_2_3 = object : Migration(2, 3) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL( + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( "ALTER TABLE host_regex ADD COLUMN type INTEGER NOT NULL DEFAULT $REGEX_TYPE_HOST" ) } @@ -64,8 +70,8 @@ object DatabaseModule { private val MIGRATION_3_4 = object : Migration(3, 4) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("DROP TABLE credentials") + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE credentials") } } } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/di/qualifiers.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/di/qualifiers.kt index a6bdad67a..5c75535e6 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/di/qualifiers.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/di/qualifiers.kt @@ -8,12 +8,8 @@ import javax.inject.Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class TorrentNotification -@Qualifier @Retention(AnnotationRetention.BINARY) annotation class DownloadNotification - @Qualifier @Retention(AnnotationRetention.BINARY) annotation class TorrentSummaryNotification -@Qualifier @Retention(AnnotationRetention.BINARY) annotation class DownloadSummaryNotification - @Qualifier @Retention(AnnotationRetention.BINARY) annotation class ClassicClient @Qualifier @Retention(AnnotationRetention.BINARY) annotation class DOHClient 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 new file mode 100644 index 000000000..b43643f6f --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/model/ServicePickerAdapter.kt @@ -0,0 +1,31 @@ +package com.github.livingwithhippos.unchained.downloaddetails.model + +import androidx.recyclerview.widget.DiffUtil +import com.github.livingwithhippos.unchained.R +import com.github.livingwithhippos.unchained.data.local.RemoteServiceDetails +import com.github.livingwithhippos.unchained.utilities.DataBindingAdapter + +class ServicePickerAdapter(listener: ServicePickerListener) : + DataBindingAdapter(DiffCallback(), listener) { + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: RemoteServiceDetails, + newItem: RemoteServiceDetails, + ): Boolean = oldItem.service.id == newItem.service.id + + override fun areContentsTheSame( + oldItem: RemoteServiceDetails, + newItem: RemoteServiceDetails, + ): Boolean { + // content does not change on update + return true + } + } + + override fun getItemViewType(position: Int) = R.layout.item_list_service_picker +} + +interface ServicePickerListener { + fun onServiceClick(serviceDetails: RemoteServiceDetails) +} 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 668c753bc..11afc9c13 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 @@ -1,37 +1,57 @@ package com.github.livingwithhippos.unchained.downloaddetails.view +import android.annotation.SuppressLint +import android.app.UiModeManager import android.content.ActivityNotFoundException import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.ColorDrawable import android.net.Uri import android.os.Bundle +import android.util.Size +import android.view.Gravity import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.widget.ImageView +import android.widget.PopupWindow +import android.widget.TextView +import androidx.appcompat.widget.PopupMenu +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.MenuHost import androidx.core.view.MenuProvider import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs 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.RemoteServiceType +import com.github.livingwithhippos.unchained.data.local.serviceTypeMap import com.github.livingwithhippos.unchained.data.model.Alternative import com.github.livingwithhippos.unchained.data.model.DownloadItem import com.github.livingwithhippos.unchained.databinding.FragmentDownloadDetailsBinding import com.github.livingwithhippos.unchained.downloaddetails.model.AlternativeDownloadAdapter import com.github.livingwithhippos.unchained.downloaddetails.viewmodel.DownloadDetailsMessage import com.github.livingwithhippos.unchained.downloaddetails.viewmodel.DownloadDetailsViewModel +import com.github.livingwithhippos.unchained.downloaddetails.viewmodel.DownloadEvent import com.github.livingwithhippos.unchained.lists.view.ListState import com.github.livingwithhippos.unchained.utilities.EventObserver import com.github.livingwithhippos.unchained.utilities.RD_STREAMING_URL -import com.github.livingwithhippos.unchained.utilities.download.ProgressCallback import com.github.livingwithhippos.unchained.utilities.extension.copyToClipboard +import com.github.livingwithhippos.unchained.utilities.extension.getAvailableSpace import com.github.livingwithhippos.unchained.utilities.extension.openExternalWebPage import com.github.livingwithhippos.unchained.utilities.extension.showToast import dagger.hilt.android.AndroidEntryPoint @@ -48,10 +68,13 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { private val args: DownloadDetailsFragmentArgs by navArgs() + private val deviceServiceMap: MutableMap> = mutableMapOf() + + @SuppressLint("SetTextI18n") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { val detailsBinding = FragmentDownloadDetailsBinding.inflate(inflater, container, false) @@ -80,7 +103,7 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { } }, viewLifecycleOwner, - Lifecycle.State.RESUMED + Lifecycle.State.RESUMED, ) detailsBinding.details = args.details @@ -98,12 +121,15 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { detailsBinding.showOpen = viewModel.getButtonVisibilityPreference(SHOW_OPEN_BUTTON) detailsBinding.showCopy = viewModel.getButtonVisibilityPreference(SHOW_COPY_BUTTON) detailsBinding.showDownload = viewModel.getButtonVisibilityPreference(SHOW_DOWNLOAD_BUTTON) - detailsBinding.showKodi = viewModel.getButtonVisibilityPreference(SHOW_KODI_BUTTON) + detailsBinding.showStreaming = + viewModel.getButtonVisibilityPreference(SHOW_STREAMING_BUTTON) detailsBinding.showLocalPlay = viewModel.getButtonVisibilityPreference(SHOW_MEDIA_BUTTON) detailsBinding.showLoadStream = - viewModel.getButtonVisibilityPreference(SHOW_LOAD_STREAM_BUTTON) - detailsBinding.showStreamBrowser = - viewModel.getButtonVisibilityPreference(SHOW_STREAM_BROWSER_BUTTON) + viewModel.getButtonVisibilityPreference(SHOW_TRANSCODING_BUTTON) + + detailsBinding.fabPickStreaming.setOnClickListener { popView -> + manageStreamingPopup(popView) + } viewModel.streamLiveData.observe(viewLifecycleOwner) { if (it != null) { @@ -118,8 +144,8 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { "h264WebM", "h264WebM", it.h264WebM.link, - getString(R.string.h264_webm), - getString(R.string.streaming) + getString(R.string.streaming), + "h264 WebM", ) ) streams.add( @@ -127,27 +153,21 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { "liveMP4", "liveMP4", it.liveMP4.link, - getString(R.string.liveMP4), - getString(R.string.streaming) + getString(R.string.streaming), + "mp4", ) ) streams.add( Alternative( "apple", - "apple", + "m3u8", it.apple.link, - getString(R.string.apple), - getString(R.string.streaming) + getString(R.string.streaming), + "m3u8", ) ) streams.add( - Alternative( - "dash", - "dash", - it.dash.link, - getString(R.string.dash), - getString(R.string.streaming) - ) + Alternative("dash", "mpd", it.dash.link, getString(R.string.streaming), "mpd") ) if (!args.details.alternative.isNullOrEmpty()) @@ -161,11 +181,11 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { viewLifecycleOwner, EventObserver { // todo: check returned value (it) - activity?.baseContext?.showToast(R.string.download_removed) - // if deleted go back - activity?.onBackPressed() + context?.showToast(R.string.download_removed) activityViewModel.setListState(ListState.UpdateDownload) - } + // if deleted go back + findNavController().popBackStack() + }, ) setFragmentResultListener("deleteActionKey") { _, bundle -> @@ -174,12 +194,12 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { } viewModel.messageLiveData.observe(viewLifecycleOwner) { - when (val content = it.getContentIfNotHandled()) { + when (it.getContentIfNotHandled()) { is DownloadDetailsMessage.KodiError -> { - context?.showToast(R.string.kodi_connection_error) + context?.showToast(R.string.connection_error) } is DownloadDetailsMessage.KodiSuccess -> { - context?.showToast(R.string.kodi_connection_successful) + context?.showToast(R.string.connection_successful) } DownloadDetailsMessage.KodiMissingCredentials -> { context?.showToast(R.string.kodi_configure_credentials) @@ -187,18 +207,313 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { DownloadDetailsMessage.KodiMissingDefault -> { context?.showToast(R.string.kodi_missing_default) } - is DownloadDetailsMessage.KodiShowPicker -> { - val dialog = KodiServerPickerDialog() + else -> {} + } + } + + lifecycle.coroutineScope.launch { + viewModel.devicesAndServices().collect { + // used to populate the menu + deviceServiceMap.clear() + deviceServiceMap.putAll(it) + } + } + + viewModel.eventLiveData.observe(viewLifecycleOwner) { + when (val content = it.peekContent()) { + is DownloadEvent.DefaultDeviceService -> { + // send media to default device + } + is DownloadEvent.DeviceAndServices -> { + // used to populate the menu + deviceServiceMap.clear() + deviceServiceMap.putAll(content.devicesServices) + } + is DownloadEvent.KodiDevices -> {} + } + } + + return detailsBinding.root + } + + private fun showBasicStreamingPopup(v: View, url: String?) { + + 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 popup = PopupMenu(requireContext(), v) + popup.menuInflater.inflate(R.menu.basic_streaming_popup, popup.menu) + + if ( + recentService == -1 || recentService == defaultService?.id || recentServiceItem == null + ) { + popup.menu.findItem(R.id.recent_service).isVisible = false + } else { + val serviceName = getString(serviceTypeMap[recentServiceItem.type]!!.nameRes) + popup.menu.findItem(R.id.recent_service).title = + getString(R.string.recent_service_format, serviceName) + } + + if (defaultDevice == null || defaultService == null) { + popup.menu.findItem(R.id.default_service).isVisible = false + } else { + val serviceName = getString(serviceTypeMap[defaultService.type]!!.nameRes) + popup.menu.findItem(R.id.default_service).title = + getString(R.string.default_service_format, serviceName) + } + + if (servicesNumber == 0) { + popup.menu.findItem(R.id.pick_service).isVisible = false + } + + if (url != null) { + popup.menu.findItem(R.id.browser_streaming).isVisible = false + } + + popup.setOnMenuItemClickListener { menuItem: MenuItem -> + // 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) { + + val serviceType: RemoteServiceType = + getServiceType(recentServiceItem.type)!! + playOnDeviceService( + url ?: args.details.download, + recentDeviceItem, + recentServiceItem, + serviceType, + ) + } + } + R.id.default_service -> { + if (defaultDevice != null && defaultService != null) { + val serviceType: RemoteServiceType = getServiceType(defaultService.type)!! + playOnDeviceService( + url ?: args.details.download, + defaultDevice.key, + defaultService, + serviceType, + ) + } + } + R.id.pick_service -> { + val dialog = ServicePickerDialog() val bundle = Bundle() - bundle.putString("url", content.url) + bundle.putString("downloadUrl", url ?: args.details.download) dialog.arguments = bundle - dialog.show(parentFragmentManager, "KodiServerPickerDialog") + dialog.show(parentFragmentManager, "ServicePickerDialog") + } + R.id.browser_streaming -> { + onBrowserStreamsClick(args.details.id) } - null -> {} } + true } - return detailsBinding.root + popup.setOnDismissListener { + // Respond to popup being dismissed. + } + // Show the popup menu. + popup.show() + } + + @SuppressLint("SetTextI18n") + private fun manageStreamingPopup(popView: View, url: String? = null) { + val uiModeManager: UiModeManager = + requireContext().getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + // custom popup menu does not work on android tv (emulator at least), maybe it's the size + // check + if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) { + showBasicStreamingPopup(popView, url) + return + } + val popup: PopupWindow = getFullStreamingPopupWindow(popView) + + 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 } + + // get all the services of the corresponding menu item + // populate according to results + val defaultLayout = + popup.contentView.findViewById(R.id.defaultServiceLayout) + + if (defaultService != null) { + val serviceType: RemoteServiceType? = getServiceType(defaultService.type) + if (serviceType != null) { + defaultLayout + .findViewById(R.id.serviceIcon) + .setImageResource(serviceType.iconRes) + 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}" + + defaultLayout.setOnClickListener { + if (popup.isShowing) popup.dismiss() + playOnDeviceService( + url ?: args.details.download, + defaultDevice.key, + defaultService, + serviceType, + ) + } + } else { + defaultLayout.visibility = View.GONE + } + } else { + defaultLayout.visibility = View.GONE + } + + 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 } + } + if (recentServiceItem != null) { + val recentDeviceItem = + deviceServiceMap.keys.firstOrNull { it.id == recentServiceItem.device } + val serviceType: RemoteServiceType? = getServiceType(recentServiceItem.type) + if (recentDeviceItem != null && serviceType != null) { + recentLayout + .findViewById(R.id.recentServiceIcon) + .setImageResource(serviceType.iconRes) + recentLayout.findViewById(R.id.recentServiceName).text = + recentServiceItem.name + // ip from device, port from service + recentLayout.findViewById(R.id.recentServiceAddress).text = + "${recentDeviceItem.address}:${recentServiceItem.port}" + + recentLayout.setOnClickListener { + if (popup.isShowing) popup.dismiss() + playOnDeviceService( + url ?: args.details.download, + recentDeviceItem, + recentServiceItem, + serviceType, + ) + } + } else { + recentLayout.visibility = View.GONE + } + } else { + recentLayout.visibility = View.GONE + } + } else { + recentLayout.visibility = View.GONE + } + + val pickerLayout = popup.contentView.findViewById(R.id.pickServiceLayout) + val servicesNumber = deviceServiceMap.values.sumOf { it.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() + + val dialog = ServicePickerDialog() + val bundle = Bundle() + bundle.putString("downloadUrl", url ?: args.details.download) + dialog.arguments = bundle + dialog.show(parentFragmentManager, "ServicePickerDialog") + } + + val browserLayout = + popup.contentView.findViewById(R.id.streamBrowserLayout) + // the url is passed when clicking on the streaming button in the transcoding list + // which has no "stream in browser" support + if (url != null) { + browserLayout.visibility = View.GONE + } else { + browserLayout.setOnClickListener { + onBrowserStreamsClick(args.details.id) + if (popup.isShowing) popup.dismiss() + } + } + } + + private fun playOnDeviceService( + link: String, + device: RemoteDevice, + service: RemoteService, + serviceType: RemoteServiceType, + ) { + when (serviceType) { + RemoteServiceType.KODI -> { + viewModel.openUrlOnKodi(mediaURL = link, kodiDevice = device, kodiService = service) + } + RemoteServiceType.VLC -> { + viewModel.openUrlOnVLC(mediaURL = link, vlcDevice = device, vlcService = service) + } + else -> { + // should not happen + Timber.e("Unknown service type $serviceType") + } + } + } + + private fun getFullStreamingPopupWindow(parentView: View): PopupWindow { + val screenDistances = getAvailableSpace(parentView) + val popup = + PopupWindow(parentView.context) + .apply { + isOutsideTouchable = true + val inflater = LayoutInflater.from(parentView.context) + contentView = + inflater.inflate(R.layout.popup_streaming_window, null).apply { + measure( + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + } + } + .also { popupWindow -> + popupWindow.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + if (screenDistances[2] < 600 && screenDistances[0] > 600) { + // Absolute location of the anchor view + val location = IntArray(2).apply { parentView.getLocationOnScreen(this) } + val size = + Size( + popupWindow.contentView.measuredWidth, + popupWindow.contentView.measuredHeight, + ) + popupWindow.showAtLocation( + parentView, + Gravity.TOP or Gravity.START, + location[0] - (size.width - parentView.width) / 2, + location[1] - (size.height / 2), + ) + } else { + popupWindow.showAsDropDown(parentView) + } + } + + return popup } override fun onCopyClick(text: String) { @@ -215,8 +530,8 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { } } - override fun onOpenWithKodi(url: String) { - viewModel.openKodiPickerIfNeeded(url) + override fun onOpenTranscodedStream(view: View, url: String) { + manageStreamingPopup(view, url) } override fun onLoadStreamsClick(id: String) { @@ -231,13 +546,6 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { context?.openExternalWebPage(RD_STREAMING_URL + id) } - private val tempProgressListener = - object : ProgressCallback { - override fun onProgress(progress: Double) { - Timber.d("Progress: $progress") - } - } - override fun onDownloadClick(link: String, fileName: String) { activityViewModel.enqueueDownload(link, fileName) } @@ -261,7 +569,7 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { appPackage: String, url: String, component: ComponentName? = null, - dataType: String = "video/*" + dataType: String = "video/*", ): Intent { val uri = Uri.parse(url) @@ -297,6 +605,14 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { val wvcIntent = createMediaIntent("com.instantbits.cast.webvideo", url) tryStartExternalApp(wvcIntent) } + "play_it" -> { + val wvcIntent = createMediaIntent("com.playit.videoplayer", url) + tryStartExternalApp(wvcIntent) + } + "player_just_video" -> { + val wvcIntent = createMediaIntent("com.brouken.player", url) + tryStartExternalApp(wvcIntent) + } "custom_player" -> { val customPlayerPackage = viewModel.getCustomPlayerPreference() if (customPlayerPackage.isBlank()) { @@ -312,15 +628,26 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { } } + private fun getServiceType(type: Int): RemoteServiceType? { + return when (type) { + RemoteServiceType.KODI.value -> RemoteServiceType.KODI + RemoteServiceType.VLC.value -> RemoteServiceType.VLC + RemoteServiceType.JACKETT.value -> RemoteServiceType.JACKETT + else -> { + Timber.e("Unknown service type $type") + null + } + } + } + companion object { const val SHOW_SHARE_BUTTON = "show_share_button" const val SHOW_OPEN_BUTTON = "show_open_button" const val SHOW_COPY_BUTTON = "show_copy_button" const val SHOW_DOWNLOAD_BUTTON = "show_download_button" const val SHOW_MEDIA_BUTTON = "show_media_button" - const val SHOW_KODI_BUTTON = "show_kodi" - const val SHOW_LOAD_STREAM_BUTTON = "show_load_stream_button" - const val SHOW_STREAM_BROWSER_BUTTON = "show_stream_browser_button" + const val SHOW_STREAMING_BUTTON = "show_streaming" + const val SHOW_TRANSCODING_BUTTON = "show_load_stream_button" } } @@ -329,7 +656,7 @@ interface DownloadDetailsListener { fun onOpenClick(url: String) - fun onOpenWithKodi(url: String) + fun onOpenTranscodedStream(view: View, url: String) fun onLoadStreamsClick(id: String) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/KodiServerPickerDialog.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/KodiServerPickerDialog.kt deleted file mode 100644 index a8e885b57..000000000 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/KodiServerPickerDialog.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.github.livingwithhippos.unchained.downloaddetails.view - -import android.app.Dialog -import android.os.Bundle -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.model.KodiDevice -import com.github.livingwithhippos.unchained.downloaddetails.viewmodel.DownloadDetailsViewModel -import com.github.livingwithhippos.unchained.downloaddetails.viewmodel.DownloadEvent -import com.github.livingwithhippos.unchained.settings.model.KodiDeviceAdapter -import com.github.livingwithhippos.unchained.settings.model.KodiDeviceListener -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class KodiServerPickerDialog : DialogFragment(), KodiDeviceListener { - - private val viewModel: DownloadDetailsViewModel by activityViewModels() - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return activity?.let { activity -> - - // Use the Builder class for convenient dialog construction - val builder = MaterialAlertDialogBuilder(activity) - - // Get the layout inflater - val inflater = activity.layoutInflater - - val view = inflater.inflate(R.layout.dialog_kodi_server_selection, null) - - val adapter = KodiDeviceAdapter(this) - val list = view.findViewById(R.id.rvKodiDeviceList) - list.adapter = adapter - - viewModel.eventLiveData.observe(this) { event -> - when (val content = event.getContentIfNotHandled()) { - is DownloadEvent.KodiDevices -> { - // populate the list - adapter.submitList(content.devices) - } - null -> {} - } - } - - viewModel.getKodiDevices() - - builder.setView(view).setTitle(R.string.kodi).setNegativeButton( - getString(R.string.close) - ) { dialog, _ -> - dialog.cancel() - } - // Create the AlertDialog object and return it - builder.create() - } ?: throw IllegalStateException("Activity cannot be null") - } - - override fun onEditClick(item: KodiDevice) { - val url: String = - arguments?.getString("url") ?: throw IllegalArgumentException("Url cannot be null") - viewModel.openUrlOnKodi(url, item) - this.dismiss() - } -} 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 new file mode 100644 index 000000000..8bd673670 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/ServicePickerDialog.kt @@ -0,0 +1,83 @@ +package com.github.livingwithhippos.unchained.downloaddetails.view + +import android.app.Dialog +import android.os.Bundle +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.serviceTypeMap +import com.github.livingwithhippos.unchained.downloaddetails.model.ServicePickerAdapter +import com.github.livingwithhippos.unchained.downloaddetails.model.ServicePickerListener +import com.github.livingwithhippos.unchained.downloaddetails.viewmodel.DownloadDetailsViewModel +import com.github.livingwithhippos.unchained.downloaddetails.viewmodel.DownloadEvent +import com.github.livingwithhippos.unchained.utilities.extension.showToast +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber + +@AndroidEntryPoint +class ServicePickerDialog : DialogFragment(), ServicePickerListener { + + private val viewModel: DownloadDetailsViewModel by activityViewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return activity?.let { activity -> + + // Use the Builder class for convenient dialog construction + val builder = MaterialAlertDialogBuilder(activity) + + // Get the layout inflater + val inflater = activity.layoutInflater + + val view = inflater.inflate(R.layout.dialog_service_picker, null) + + val adapter = ServicePickerAdapter(this) + val list = view.findViewById(R.id.rvServiceList) + list.adapter = adapter + + viewModel.eventLiveData.observe(this) { event -> + when (val content = event.getContentIfNotHandled()) { + is DownloadEvent.DeviceAndServices -> { + + val devSer: List = + content.devicesServices.flatMap { + it.value.map { serv -> + RemoteServiceDetails( + service = serv, + device = it.key, + type = serviceTypeMap[serv.type]!!, + ) + } + } + adapter.submitList(devSer) + } + else -> {} + } + } + + viewModel.fetchDevicesAndServices() + + builder.setView(view).setTitle(R.string.services).setNegativeButton( + getString(R.string.close) + ) { dialog, _ -> + dialog.cancel() + } + + builder.create() + } ?: throw IllegalStateException("Activity cannot be null") + } + + override fun onServiceClick(serviceDetails: RemoteServiceDetails) { + val link = arguments?.getString("downloadUrl") + if (link == null) { + Timber.e("Download url is null") + context?.showToast(R.string.error) + } else { + viewModel.openOnRemoteService(serviceDetails, link) + // show toast? + } + this.dismiss() + } +} 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 f3caa39d7..56b2329a4 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 @@ -4,18 +4,25 @@ import android.content.SharedPreferences 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.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.KodiDeviceRepository 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.StreamingRepository +import com.github.livingwithhippos.unchained.utilities.EitherResult import com.github.livingwithhippos.unchained.utilities.Event -import com.github.livingwithhippos.unchained.utilities.PreferenceKeys import com.github.livingwithhippos.unchained.utilities.postEvent import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +import timber.log.Timber /** A [ViewModel] subclass. It offers LiveData to observe the calls to the streaming endpoint */ @HiltViewModel @@ -26,7 +33,8 @@ constructor( private val streamingRepository: StreamingRepository, private val downloadRepository: DownloadRepository, private val kodiRepository: KodiRepository, - private val kodiDeviceRepository: KodiDeviceRepository, + private val remoteServiceRepository: RemoteRepository, + private val remoteDeviceRepository: RemoteDeviceRepository, ) : ViewModel() { val streamLiveData = MutableLiveData() @@ -49,83 +57,127 @@ constructor( } } - fun openUrlOnKodi(url: String, customDevice: KodiDevice? = null) { + fun openUrlOnKodi(mediaURL: String, kodiDevice: RemoteDevice, kodiService: RemoteService) { viewModelScope.launch { - val device = customDevice ?: kodiDeviceRepository.getDefault() - if (device != null) { + try { val response = kodiRepository.openUrl( - device.address, - device.port, - url, - device.username, - device.password + kodiDevice.address, + kodiService.port, + mediaURL, + kodiService.username, + kodiService.password, ) if (response != null) messageLiveData.postEvent(DownloadDetailsMessage.KodiSuccess) else messageLiveData.postEvent(DownloadDetailsMessage.KodiError) - } else { - val allDevices = kodiDeviceRepository.getDevices() - if (allDevices.isNotEmpty()) - messageLiveData.postEvent(DownloadDetailsMessage.KodiMissingDefault) - else messageLiveData.postEvent(DownloadDetailsMessage.KodiMissingCredentials) + } catch (e: Exception) { + Timber.e("Error playing on Kodi: ${e.message}") + messageLiveData.postEvent(DownloadDetailsMessage.KodiError) } } } - fun openKodiPickerIfNeeded(url: String) { + fun openUrlOnVLC(mediaURL: String, vlcDevice: RemoteDevice, vlcService: RemoteService) { + viewModelScope.launch { - if (preferences.getBoolean(PreferenceKeys.Kodi.SERVER_PICKER, false)) { - val devices = kodiDeviceRepository.getDevices() - // if there is only one device do not show the picker - if (devices.size == 1) openUrlOnKodi(url) - else if (devices.isEmpty()) - messageLiveData.postEvent(DownloadDetailsMessage.KodiMissingDefault) - else messageLiveData.postEvent(DownloadDetailsMessage.KodiShowPicker(url)) - } else openUrlOnKodi(url) + try { + val response = + remoteServiceRepository.openUrl( + vlcDevice.address, + vlcService.port, + mediaURL, + vlcService.username, + vlcService.password, + ) + // todo: use a single message valid for all players + if (response is EitherResult.Failure) + messageLiveData.postEvent(DownloadDetailsMessage.KodiError) + else messageLiveData.postEvent(DownloadDetailsMessage.KodiSuccess) + } catch (e: Exception) { + Timber.e("Error playing on VLC: ${e.message}") + messageLiveData.postEvent(DownloadDetailsMessage.KodiError) + } } } - fun getKodiDevices() { + fun getDefaultPlayer(): String? { + return preferences.getString("default_media_player", null) + } + + fun getButtonVisibilityPreference(buttonKey: String, default: Boolean = true): Boolean { + return preferences.getBoolean(buttonKey, default) + } + + fun getCustomPlayerPreference(): String { + return preferences.getString("custom_media_player", "") ?: "" + } + fun fetchDevicesAndServices(mediaPlayerOnly: Boolean = true) { + // todo: replace other uses with [devicesAndServices] viewModelScope.launch { - val devices = kodiDeviceRepository.getDevices() - eventLiveData.postEvent(DownloadEvent.KodiDevices(devices)) + val devices: Map> = + if (mediaPlayerOnly) remoteDeviceRepository.getMediaPlayerDevicesAndServices() + else remoteDeviceRepository.getDevicesAndServices() + + eventLiveData.postEvent(DownloadEvent.DeviceAndServices(devices)) } } - fun getKodiPreference(): Boolean { - return preferences.getBoolean("show_kodi", true) + suspend fun devicesAndServices(): Flow>> { + return remoteDeviceRepository.getMediaPlayerDevicesAndServicesFlow() } - fun getDefaultPlayer(): String? { - return preferences.getString("default_media_player", null) + /** + * returns the IDs of the most recently used service, which also has its device ID the IDs are + * the DB entities' IDs + */ + fun getRecentService(): Int { + return preferences.getInt(RECENT_SERVICE_KEY, -1) } - fun getDefaultPlayerButtonVisibility(): Boolean { - return preferences.getBoolean("show_media_button", true) + private fun setRecentService(serviceId: Int) { + with(preferences.edit()) { + putInt(RECENT_SERVICE_KEY, serviceId).apply() + apply() + } } - fun getButtonVisibilityPreference(buttonKey: String, default: Boolean = true): Boolean { - return preferences.getBoolean(buttonKey, default) + fun openOnRemoteService(serviceDetails: RemoteServiceDetails, link: String) { + setRecentService(serviceDetails.service.id) + when (serviceDetails.service.type) { + RemoteServiceType.KODI.value -> { + openUrlOnKodi(link, serviceDetails.device, serviceDetails.service) + } + RemoteServiceType.VLC.value -> { + openUrlOnVLC(link, serviceDetails.device, serviceDetails.service) + } + else -> { + Timber.e("Unknown service type: ${serviceDetails.service.type}") + } + } } - fun getCustomPlayerPreference(): String { - return preferences.getString("custom_media_player", "") ?: "" + companion object { + const val RECENT_SERVICE_KEY = "RECENT_SERVICE" } } sealed class DownloadDetailsMessage { - object KodiError : DownloadDetailsMessage() + data object KodiError : DownloadDetailsMessage() - object KodiSuccess : DownloadDetailsMessage() + data object KodiSuccess : DownloadDetailsMessage() - object KodiMissingCredentials : DownloadDetailsMessage() + data object KodiMissingCredentials : DownloadDetailsMessage() - object KodiMissingDefault : DownloadDetailsMessage() - - data class KodiShowPicker(val url: String) : DownloadDetailsMessage() + data object KodiMissingDefault : DownloadDetailsMessage() } sealed class DownloadEvent { data class KodiDevices(val devices: List) : DownloadEvent() + + data class DeviceAndServices(val devicesServices: Map>) : + DownloadEvent() + + data class DefaultDeviceService(val device: RemoteDevice, val service: RemoteService) : + 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 5aeaa9fa1..6549b3fbd 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 @@ -42,9 +42,6 @@ import com.github.livingwithhippos.unchained.utilities.extension.getThemedDrawab import com.github.livingwithhippos.unchained.utilities.extension.showToast import com.google.android.material.floatingactionbutton.FloatingActionButton import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import timber.log.Timber @@ -54,9 +51,6 @@ class FolderListFragment : UnchainedFragment(), DownloadListListener { private val viewModel: FolderListViewModel by viewModels() private val args: FolderListFragmentArgs by navArgs() - private val job = Job() - private val scope = CoroutineScope(Dispatchers.Default + job) - private val mediaRegex = "\\.(webm|avi|mkv|ogg|MTS|M2TS|TS|mov|wmv|mp4|m4p|m4v|mp2|mpe|mpv|mpg|mpeg|m2v|3gp)$" .toRegex() @@ -99,7 +93,7 @@ class FolderListFragment : UnchainedFragment(), DownloadListListener { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { val binding = FragmentFolderListBinding.inflate(inflater, container, false) @@ -132,7 +126,7 @@ class FolderListFragment : UnchainedFragment(), DownloadListListener { } }, viewLifecycleOwner, - Lifecycle.State.RESUMED + Lifecycle.State.RESUMED, ) return binding.root @@ -166,7 +160,7 @@ class FolderListFragment : UnchainedFragment(), DownloadListListener { binding.rvFolderList, FolderKeyProvider(adapter), DataBindingDetailsLookup(binding.rvFolderList), - StorageStrategy.createParcelableStorage(DownloadItem::class.java) + StorageStrategy.createParcelableStorage(DownloadItem::class.java), ) .withSelectionPredicate(SelectionPredicates.createSelectAnything()) .build() @@ -208,7 +202,7 @@ class FolderListFragment : UnchainedFragment(), DownloadListListener { if (downloads.size == 1) { activityViewModel.enqueueDownload( downloads.first().download, - downloads.first().filename + downloads.first().filename, ) } else { activityViewModel.enqueueDownloads(downloads) @@ -333,7 +327,7 @@ class FolderListFragment : UnchainedFragment(), DownloadListListener { v: View, @MenuRes menuRes: Int, folderAdapter: FolderItemAdapter, - folderList: RecyclerView + folderList: RecyclerView, ) { val popup = PopupMenu(requireContext(), v) 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 d17ec27c8..9f1cde70c 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 @@ -27,7 +27,7 @@ constructor( private val savedStateHandle: SavedStateHandle, private val preferences: SharedPreferences, private val unrestrictRepository: UnrestrictRepository, - private val downloadRepository: DownloadRepository + private val downloadRepository: DownloadRepository, ) : ViewModel() { val folderLiveData = MutableLiveData>>() @@ -87,11 +87,11 @@ constructor( } private fun setRetrievedLinks(links: Int) { - savedStateHandle.set(KEY_RETRIEVED_LINKS, links) + savedStateHandle[KEY_RETRIEVED_LINKS] = links } private fun getRetrievedLinks(): Int { - return savedStateHandle.get(KEY_RETRIEVED_LINKS) ?: -1 + return savedStateHandle[KEY_RETRIEVED_LINKS] ?: -1 } fun deleteDownloadList(downloads: List) { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/DownloadPagingSource.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/DownloadPagingSource.kt index c7272aaa0..3190a6786 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/DownloadPagingSource.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/DownloadPagingSource.kt @@ -28,7 +28,7 @@ class DownloadPagingSource( LoadResult.Page( data = response, prevKey = if (page == DOWNLOAD_STARTING_PAGE_INDEX) null else page - 1, - nextKey = if (response.isEmpty()) null else page + 1 + nextKey = if (response.isEmpty()) null else page + 1, ) } catch (exception: IOException) { return LoadResult.Error(exception) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/TorrentPagingSource.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/TorrentPagingSource.kt index 30f5a4776..f0a4622f0 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/TorrentPagingSource.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/model/TorrentPagingSource.kt @@ -1,3 +1,5 @@ +package com.github.livingwithhippos.unchained.lists.model + import androidx.paging.PagingSource import androidx.paging.PagingState import com.github.livingwithhippos.unchained.data.model.TorrentItem @@ -31,7 +33,7 @@ class TorrentPagingSource( LoadResult.Page( data = response, prevKey = if (page == TORRENT_STARTING_PAGE_INDEX) null else page - 1, - nextKey = if (response.isEmpty()) null else page + 1 + nextKey = if (response.isEmpty()) null else page + 1, ) } catch (exception: IOException) { return LoadResult.Error(exception) 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 cb46c42cb..611f6eb9f 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 @@ -81,7 +81,7 @@ class ListsTabFragment : UnchainedFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { val binding: FragmentTabListsBinding = FragmentTabListsBinding.inflate(inflater, container, false) @@ -144,7 +144,7 @@ class ListsTabFragment : UnchainedFragment() { } }, viewLifecycleOwner, - Lifecycle.State.RESUMED + Lifecycle.State.RESUMED, ) val listsAdapter = ListsAdapter(this) @@ -181,7 +181,7 @@ class ListsTabFragment : UnchainedFragment() { externalUri = uri ) findNavController().navigate(action) - } + }, ) // a file has been downloaded, usually a torrent, and needs to be unrestricted @@ -198,7 +198,7 @@ class ListsTabFragment : UnchainedFragment() { ) findNavController().navigate(action) } - } + }, ) // a notification has been clicked @@ -207,7 +207,7 @@ class ListsTabFragment : UnchainedFragment() { EventObserver { torrentID -> val action = ListsTabFragmentDirections.actionListsTabToTorrentDetails(torrentID) findNavController().navigate(action) - } + }, ) viewModel.eventLiveData.observe( @@ -239,7 +239,7 @@ class ListsTabFragment : UnchainedFragment() { .actionListTabsDestToFolderListFragment2( folder = null, torrent = event.item, - linkList = null + linkList = null, ) findNavController().navigate(action) } else viewModel.unrestrictTorrent(event.item) @@ -302,7 +302,7 @@ class ListsTabFragment : UnchainedFragment() { findNavController().navigate(action) } } - } + }, ) viewModel.errorsLiveData.observe( @@ -335,7 +335,7 @@ class ListsTabFragment : UnchainedFragment() { } } } - } + }, ) return binding.root @@ -395,7 +395,7 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { val binding = FragmentDownloadsListBinding.inflate(inflater, container, false) @@ -411,7 +411,7 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener { binding.rvDownloadList, DownloadKeyProvider(downloadAdapter), DataBindingDetailsLookup(binding.rvDownloadList), - StorageStrategy.createParcelableStorage(DownloadItem::class.java) + StorageStrategy.createParcelableStorage(DownloadItem::class.java), ) .withSelectionPredicate(SelectionPredicates.createSelectAnything()) .build() @@ -455,7 +455,7 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener { if (downloads.size == 1) { activityViewModel.enqueueDownload( downloads.first().download, - downloads.first().filename + downloads.first().filename, ) } else { activityViewModel.enqueueDownloads(downloads) @@ -538,7 +538,7 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener { viewModel.postEventNotice(ListEvent.SetTab(DOWNLOADS_TAB)) } - } + }, ) activityViewModel.listStateLiveData.observe( @@ -556,7 +556,7 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener { } else -> {} } - } + }, ) viewModel.deletedDownloadLiveData.observe( @@ -589,7 +589,7 @@ class DownloadsListFragment : UnchainedFragment(), DownloadListListener { downloadAdapter.refresh() } } - } + }, ) // starts the Transformations.switchMap(queryLiveData) which otherwise won't trigger the @@ -613,7 +613,7 @@ class TorrentsListFragment : UnchainedFragment(), TorrentListListener { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { val binding = FragmentTorrentsListBinding.inflate(inflater, container, false) @@ -628,7 +628,7 @@ class TorrentsListFragment : UnchainedFragment(), TorrentListListener { binding.rvTorrentList, TorrentKeyProvider(torrentAdapter), DataBindingDetailsLookup(binding.rvTorrentList), - StorageStrategy.createParcelableStorage(TorrentItem::class.java) + StorageStrategy.createParcelableStorage(TorrentItem::class.java), ) .withSelectionPredicate(SelectionPredicates.createSelectAnything()) .build() @@ -766,7 +766,7 @@ class TorrentsListFragment : UnchainedFragment(), TorrentListListener { torrentAdapter.refresh() } } - } + }, ) activityViewModel.listStateLiveData.observe( @@ -784,7 +784,7 @@ class TorrentsListFragment : UnchainedFragment(), TorrentListListener { } else -> {} } - } + }, ) return binding.root @@ -809,7 +809,7 @@ class TorrentsListFragment : UnchainedFragment(), TorrentListListener { ListsTabFragmentDirections.actionListTabsDestToFolderListFragment2( folder = null, torrent = item, - linkList = null + linkList = null, ) if (controller.currentDestination?.id == R.id.list_tabs_dest) controller.navigate(action) @@ -848,8 +848,6 @@ sealed class ListState { data object UpdateTorrent : ListState() data object UpdateDownload : ListState() - - data object Ready : ListState() } interface SelectedItemsButtonsListener { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/viewmodel/DownloadDialogViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/viewmodel/DownloadDialogViewModel.kt index 4d6c8bc39..114eea842 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/viewmodel/DownloadDialogViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/viewmodel/DownloadDialogViewModel.kt @@ -11,11 +11,11 @@ class DownloadDialogViewModel @Inject constructor(private val savedStateHandle: ViewModel() { fun setItem(item: DownloadItem?) { - item.let { savedStateHandle.set(KEY_ITEM, it) } + item.let { savedStateHandle[KEY_ITEM] = it } } fun getItem(): DownloadItem? { - return savedStateHandle.get(KEY_ITEM) + return savedStateHandle[KEY_ITEM] } companion object { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/viewmodel/ListTabsViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/viewmodel/ListTabsViewModel.kt index bc98e6141..2f74d8e76 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/viewmodel/ListTabsViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/lists/viewmodel/ListTabsViewModel.kt @@ -1,6 +1,5 @@ package com.github.livingwithhippos.unchained.lists.viewmodel -import TorrentPagingSource import android.content.SharedPreferences import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -20,6 +19,7 @@ import com.github.livingwithhippos.unchained.data.repository.DownloadRepository import com.github.livingwithhippos.unchained.data.repository.TorrentsRepository import com.github.livingwithhippos.unchained.data.repository.UnrestrictRepository import com.github.livingwithhippos.unchained.lists.model.DownloadPagingSource +import com.github.livingwithhippos.unchained.lists.model.TorrentPagingSource import com.github.livingwithhippos.unchained.utilities.DOWNLOADS_TAB import com.github.livingwithhippos.unchained.utilities.EitherResult import com.github.livingwithhippos.unchained.utilities.Event @@ -41,7 +41,7 @@ constructor( private val preferences: SharedPreferences, private val downloadRepository: DownloadRepository, private val torrentsRepository: TorrentsRepository, - private val unrestrictRepository: UnrestrictRepository + private val unrestrictRepository: UnrestrictRepository, ) : ViewModel() { private val MAX_PAGE_SIZE = 2500 @@ -102,54 +102,16 @@ constructor( } } - fun getPagingSize(): Int { + private fun getPagingSize(): Int { return min(preferences.getInt("paging_size", 50), MAX_PAGE_SIZE) } - fun downloadTorrentFolder(torrent: TorrentItem) { - viewModelScope.launch { - val items = unrestrictRepository.getUnrestrictedLinkList(torrent.links) - val values = - items.filterIsInstance>().map { it.success } - val errors = - items.filterIsInstance>().map { - it.failure - } - - downloadItemLiveData.postEvent(values) - if (errors.isNotEmpty()) errorsLiveData.postEvent(errors) - } - } - - fun deleteTorrent(id: String) { - viewModelScope.launch { - val deleted = torrentsRepository.deleteTorrent(id) - when (deleted) { - is EitherResult.Failure -> { - errorsLiveData.postEvent(listOf(deleted.failure)) - deletedTorrentLiveData.postEvent(TORRENT_NOT_DELETED) - } - is EitherResult.Success -> { - deletedTorrentLiveData.postEvent(TORRENT_DELETED) - } - } - } - } - - fun deleteDownload(id: String) { - viewModelScope.launch { - val deleted = downloadRepository.deleteDownload(id) - if (deleted == null) deletedDownloadLiveData.postEvent(DOWNLOAD_NOT_DELETED) - else deletedDownloadLiveData.postEvent(DOWNLOAD_DELETED) - } - } - fun setSelectedTab(tabID: Int) { - savedStateHandle.set(KEY_SELECTED_TAB, tabID) + savedStateHandle[KEY_SELECTED_TAB] = tabID } fun getSelectedTab(): Int { - return savedStateHandle.get(KEY_SELECTED_TAB) ?: DOWNLOADS_TAB + return savedStateHandle[KEY_SELECTED_TAB] ?: DOWNLOADS_TAB } fun setListFilter(query: String?) { 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 53748529b..8cf733c4c 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 @@ -22,7 +22,6 @@ import com.github.livingwithhippos.unchained.base.UnchainedFragment import com.github.livingwithhippos.unchained.data.model.APIError import com.github.livingwithhippos.unchained.data.model.EmptyBodyError import com.github.livingwithhippos.unchained.data.model.NetworkError -import com.github.livingwithhippos.unchained.data.repository.DownloadResult import com.github.livingwithhippos.unchained.databinding.NewDownloadFragmentBinding import com.github.livingwithhippos.unchained.lists.view.ListState import com.github.livingwithhippos.unchained.newdownload.viewmodel.Link @@ -45,10 +44,7 @@ import com.github.livingwithhippos.unchained.utilities.extension.isSimpleWebUrl import com.github.livingwithhippos.unchained.utilities.extension.isTorrent import com.github.livingwithhippos.unchained.utilities.extension.isWebUrl import dagger.hilt.android.AndroidEntryPoint -import java.io.File import java.io.IOException -import java.util.regex.Matcher -import java.util.regex.Pattern import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber @@ -69,7 +65,7 @@ class NewDownloadFragment : UnchainedFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { val binding = NewDownloadFragmentBinding.inflate(inflater, container, false) @@ -92,7 +88,7 @@ class NewDownloadFragment : UnchainedFragment() { linkDetails ) findNavController().navigate(action) - } + }, ) viewModel.folderLiveData.observe( @@ -104,10 +100,10 @@ class NewDownloadFragment : UnchainedFragment() { NewDownloadFragmentDirections.actionNewDownloadDestToFolderListFragment( folder = folder, torrent = null, - linkList = null + linkList = null, ) findNavController().navigate(action) - } + }, ) viewModel.linkLiveData.observe( @@ -121,7 +117,7 @@ class NewDownloadFragment : UnchainedFragment() { NewDownloadFragmentDirections.actionNewDownloadDestToFolderListFragment( linkList = link.links.toTypedArray(), folder = null, - torrent = null + torrent = null, ) findNavController().navigate(action) } @@ -138,7 +134,7 @@ class NewDownloadFragment : UnchainedFragment() { } else -> {} } - } + }, ) activityViewModel.downloadedFileLiveData.observe( @@ -148,7 +144,7 @@ class NewDownloadFragment : UnchainedFragment() { // no need to recheck the extension since it was checked on download // if (uri?.path?.endsWith(".torrent") == true) if (uri?.path != null) loadTorrent(binding, uri) - } + }, ) viewModel.networkExceptionLiveData.observe( @@ -225,7 +221,7 @@ class NewDownloadFragment : UnchainedFragment() { viewModel.postMessage(getString(R.string.network_error)) } } - } + }, ) @SuppressLint("ShowToast") @@ -243,7 +239,7 @@ class NewDownloadFragment : UnchainedFragment() { currentToast.show() lastToastTime = System.currentTimeMillis() } - } + }, ) } @@ -256,77 +252,111 @@ class NewDownloadFragment : UnchainedFragment() { authState is FSMAuthenticationState.AuthenticatedOpenToken ) { val link: String = binding.tiLink.text.toString().trim() - when { - // this must be before the link.isWebUrl() check or it won't trigger - link.isTorrent() -> { - val action = - NewDownloadFragmentDirections - .actionNewDownloadFragmentToTorrentProcessingFragment(link = link) - findNavController().navigate(action) - // viewModel.postMessage(getString(R.string.loading_torrent)) - // enableButtons(binding, false) - /** - * DownloadManager does not support insecure (https) links anymore to add - * support for it, follow these instructions - * [https://stackoverflow.com/a/50834600] val secureLink = if - * (link.startsWith("http://")) link.replaceFirst( "http:", "https:" ) else - * link downloadTorrent(Uri.parse(secureLink)) - */ - // downloadTorrentToCache(binding, link) - } - link.isMagnet() -> { - val action = - NewDownloadFragmentDirections - .actionNewDownloadFragmentToTorrentProcessingFragment(link = link) - findNavController().navigate(action) - // viewModel.postMessage(getString(R.string.loading_magnet_link)) - // enableButtons(binding, false) - // viewModel.fetchAddedMagnet(link) - } - link.isWebUrl() || link.isSimpleWebUrl() -> { - viewModel.postMessage(getString(R.string.loading_host_link)) - enableButtons(binding, false) - - var password: String? = binding.tePassword.text.toString() - // we don't pass the password if it is blank. - // N.B. it won't work if your password is made up of spaces but then again - // you deserve - // it - if (password.isNullOrBlank()) password = null - val remote: Int? = - if (binding.switchRemote.isChecked) REMOTE_TRAFFIC_ON else null - - viewModel.fetchUnrestrictedLink(link, password, remote) - } - link.isBlank() -> { - viewModel.postMessage(getString(R.string.please_insert_url)) - } - link.isContainerWebLink() -> { - viewModel.unrestrictContainer(link) - } - link.split("\n").firstOrNull()?.trim()?.isWebUrl() == true -> { - // todo: support list of magnets/torrents - val splitLinks: List = - link.split("\n").map { it.trim() }.filter { it.length > 10 } - viewModel.postMessage(getString(R.string.loading)) - enableButtons(binding, false) - - // new folder list, alert the list fragment that it needs updating - activityViewModel.setListState(ListState.UpdateDownload) - val action = - NewDownloadFragmentDirections.actionNewDownloadDestToFolderListFragment( - folder = null, - torrent = null, - linkList = splitLinks.toTypedArray() - ) - findNavController().navigate(action) - } - else -> { - Timber.w("Invalid link: $link") - viewModel.postMessage(getString(R.string.invalid_url)) + val splitLinks: List = + link + .split("\n") + .dropWhile { it.isBlank() } + .map { it.trim() } + .filter { + it.length > 10 && + (it.isTorrent() || + it.isMagnet() || + it.isWebUrl() || + it.isSimpleWebUrl() || + it.isContainerWebLink()) + } + + if (splitLinks.isEmpty()) { + Timber.w("Invalid link: $link") + viewModel.postMessage(getString(R.string.invalid_url)) + return@setOnClickListener + } + + if (splitLinks.size == 1) { + val link = splitLinks.first() + + when { + // this must be before the link.isWebUrl() check or it won't trigger + link.isTorrent() -> { + val action = + NewDownloadFragmentDirections + .actionNewDownloadFragmentToTorrentProcessingFragment( + link = link + ) + findNavController().navigate(action) + + // viewModel.postMessage(getString(R.string.loading_torrent)) + // enableButtons(binding, false) + /** + * DownloadManager does not support insecure (https) links anymore to + * add support for it, follow these instructions + * [https://stackoverflow.com/a/50834600] val secureLink = if + * (link.startsWith("http://")) link.replaceFirst( "http:", "https:" ) + * else link downloadTorrent(Uri.parse(secureLink)) + */ + // downloadTorrentToCache(binding, link) + } + link.isMagnet() -> { + // this one must stay above link.isWebUrl() || link.isSimpleWebUrl() + // because some magnets have http in their link, getting recognized as + // urls + val action = + NewDownloadFragmentDirections + .actionNewDownloadFragmentToTorrentProcessingFragment( + link = link + ) + findNavController().navigate(action) + } + // put this above the web url checks since this is a web link too + link.isContainerWebLink() -> { + viewModel.unrestrictContainer(link) + } + link.isWebUrl() || link.isSimpleWebUrl() -> { + viewModel.postMessage(getString(R.string.loading_host_link)) + enableButtons(binding, false) + + var password: String? = binding.tePassword.text.toString() + // we don't pass the password if it is blank. + // N.B. it won't work if your password is made up of spaces but then + // again + // you deserve it + if (password.isNullOrBlank()) password = null + val remote: Int? = + if (binding.switchRemote.isChecked) REMOTE_TRAFFIC_ON else null + + viewModel.fetchUnrestrictedLink(link, password, remote) + } + else -> { + Timber.w("Invalid link: $link") + viewModel.postMessage(getString(R.string.invalid_url)) + } } + + return@setOnClickListener } + + val multipleLinks: List = + splitLinks.filter { it.isWebUrl() || it.isSimpleWebUrl() } + + if (multipleLinks.isEmpty()) { + Timber.w("Invalid link: $link") + viewModel.postMessage(getString(R.string.invalid_url)) + return@setOnClickListener + } + + viewModel.postMessage(getString(R.string.loading)) + enableButtons(binding, false) + + // new folder list, alert the list fragment that it needs updating + activityViewModel.setListState(ListState.UpdateDownload) + val action = + NewDownloadFragmentDirections.actionNewDownloadDestToFolderListFragment( + folder = null, + torrent = null, + linkList = multipleLinks.toTypedArray(), + ) + findNavController().navigate(action) } else viewModel.postMessage(getString(R.string.premium_needed)) } @@ -408,7 +438,7 @@ class NewDownloadFragment : UnchainedFragment() { arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), null, null, - null + null, ) ?.use { metaCursor -> if (metaCursor.moveToFirst()) { @@ -463,39 +493,6 @@ class NewDownloadFragment : UnchainedFragment() { } } - private fun loadCachedTorrent( - binding: NewDownloadFragmentBinding, - cacheDir: File, - fileName: String - ) { - try { - viewModel.postMessage(getString(R.string.loading_torrent_file)) - val cacheFile = File(cacheDir, fileName) - cacheFile.inputStream().use { inputStream -> - val buffer: ByteArray = inputStream.readBytes() - viewModel.fetchUploadedTorrent(buffer) - } - } catch (exception: Exception) { - when (exception) { - is java.io.FileNotFoundException -> { - Timber.e("Torrent conversion: file not found: ${exception.message}") - } - is IOException -> { - Timber.e( - "Torrent conversion: IOException error getting the file: ${exception.message}" - ) - } - else -> { - Timber.e( - "Torrent conversion: Other error getting the file: ${exception.message}" - ) - } - } - enableButtons(binding, true) - viewModel.postMessage(getString(R.string.error_loading_torrent)) - } - } - private fun loadTorrent(binding: NewDownloadFragmentBinding, uri: Uri) { // https://developer.android.com/training/data-storage/shared/documents-files#open try { @@ -557,37 +554,4 @@ class NewDownloadFragment : UnchainedFragment() { viewModel.postMessage(getString(R.string.error_loading_file)) } } - - private fun downloadTorrentToCache(binding: NewDownloadFragmentBinding, link: String) { - val nameRegex = "/([^/]+\\.torrent)\$" - val m: Matcher = Pattern.compile(nameRegex).matcher(link) - val torrentName = if (m.find()) m.group(1) else null - val cacheDir = context?.cacheDir - if (!torrentName.isNullOrBlank() && cacheDir != null) { - lifecycleScope.launch { - activityViewModel.downloadFileToCache(link, torrentName, cacheDir).observe( - viewLifecycleOwner - ) { - when (it) { - is DownloadResult.End -> { - loadCachedTorrent(binding, cacheDir, it.fileName) - } - DownloadResult.Failure -> { - viewModel.postMessage( - getString(R.string.download_not_started_format, torrentName) - ) - } - is DownloadResult.Progress -> { - Timber.d("$torrentName progress: ${it.percent}") - } - DownloadResult.WrongURL -> { - viewModel.postMessage( - getString(R.string.download_not_started_format, torrentName) - ) - } - } - } - } - } - } } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/viewmodel/NewDownloadViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/viewmodel/NewDownloadViewModel.kt index 504ce2851..5ede3ef92 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/viewmodel/NewDownloadViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/viewmodel/NewDownloadViewModel.kt @@ -116,13 +116,9 @@ constructor( sealed class Link { data class Host(val link: String) : Link() - data class Folder(val link: String) : Link() - - data class Magnet(val link: String) : Link() - data class Torrent(val upload: UploadedTorrent) : Link() data class Container(val links: List) : Link() - object RetrievalError : Link() + data object RetrievalError : 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 3c8ef813d..18d6c0be7 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 @@ -28,7 +28,7 @@ import timber.log.Timber class Parser( private val preferences: SharedPreferences, private val classicClient: OkHttpClient, - private val dohClient: OkHttpClient + private val dohClient: OkHttpClient, ) { private fun getClient(): OkHttpClient { @@ -59,7 +59,7 @@ class Parser( url = plugin.url, query = currentQuery, category = currentCategory, - page = page + page = page, ) emit(ParserResult.SearchStarted(-1)) @@ -94,7 +94,7 @@ class Parser( plugin.download.regexes, s, link, - plugin.url + plugin.url, ) emit(ParserResult.SingleResult(scrapedItem)) } else { @@ -113,7 +113,7 @@ class Parser( plugin.download.directParser, plugin.download.regexes, source, - plugin.url + plugin.url, ) ) ) @@ -126,7 +126,7 @@ class Parser( plugin.download.tableLink, plugin.download.regexes, source, - plugin.url + plugin.url, ) ) ) @@ -139,7 +139,7 @@ class Parser( plugin.download.indirectTableLink, plugin.download.regexes, source, - plugin.url + plugin.url, ) emit(ParserResult.SearchStarted(links.size)) links.forEach { @@ -150,7 +150,7 @@ class Parser( plugin.download.regexes, itemSource, it, - plugin.url + plugin.url, ) emit(ParserResult.SingleResult(scrapedItem)) } else { @@ -170,7 +170,7 @@ class Parser( tableParser: TableParser, regexes: PluginRegexes, source: String, - baseUrl: String + baseUrl: String, ): List { val tableLinks = mutableListOf() val doc: Document = Jsoup.parse(source) @@ -210,7 +210,7 @@ class Parser( parseSingle( it, columns[tableParser.columns.detailsColumn].html(), - baseUrl + baseUrl, ) if (details != null) tableLinks.add(details) @@ -234,7 +234,7 @@ class Parser( regexes: PluginRegexes, source: String, link: String, - baseUrl: String + baseUrl: String, ): ScrapedItem { var name = "" @@ -333,7 +333,7 @@ class Parser( magnets = magnets.toList(), torrents = torrents.toList(), hosting = hosting.toList(), - isCached = false + isCached = false, ) } @@ -390,7 +390,7 @@ class Parser( parseSingle( regexes.nameRegex, columns[tableLink.columns.nameColumn].html(), - baseUrl + baseUrl, ) ?: "" ) @@ -399,35 +399,35 @@ class Parser( parseSingle( regexes.detailsRegex, columns[tableLink.columns.detailsColumn].html(), - baseUrl + baseUrl, ) if (tableLink.columns.seedersColumn != null) seeders = parseSingle( regexes.seedersRegex, columns[tableLink.columns.seedersColumn].html(), - baseUrl + baseUrl, ) if (tableLink.columns.addedDateColumn != null) addedDate = parseSingle( regexes.dateAddedRegex, columns[tableLink.columns.addedDateColumn].html(), - baseUrl + baseUrl, ) if (tableLink.columns.leechersColumn != null) leechers = parseSingle( regexes.leechersRegex, columns[tableLink.columns.leechersColumn].html(), - baseUrl + baseUrl, ) if (tableLink.columns.sizeColumn != null) size = parseSingle( regexes.sizeRegex, columns[tableLink.columns.sizeColumn].html(), - baseUrl + baseUrl, ) if (tableLink.columns.magnetColumn != null) magnets.addAll( @@ -437,7 +437,7 @@ class Parser( .html() .removeWebFormatting(), baseUrl, - toLowerCase = true + toLowerCase = true, ) ) if (tableLink.columns.torrentColumn != null) @@ -445,7 +445,7 @@ class Parser( parseList( regexes.torrentRegexes, columns[tableLink.columns.torrentColumn].html(), - baseUrl + baseUrl, ) ) if (tableLink.columns.hostingColumn != null) @@ -453,7 +453,7 @@ class Parser( parseList( regexes.hostingRegexes, columns[tableLink.columns.hostingColumn].html(), - baseUrl + baseUrl, ) ) } catch (e: IndexOutOfBoundsException) { @@ -475,7 +475,7 @@ class Parser( magnets = magnets.toList(), torrents = torrents.toList(), hosting = hosting.toList(), - isCached = false + isCached = false, ) ) } @@ -492,7 +492,7 @@ class Parser( .url(url) .header( "User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36", ) .build() @@ -595,7 +595,7 @@ class Parser( customRegexes: List?, source: String, url: String, - toLowerCase: Boolean = false + toLowerCase: Boolean = false, ): List { if (customRegexes.isNullOrEmpty()) return emptyList() val results = mutableSetOf() @@ -633,7 +633,7 @@ class Parser( regexpsGroup: RegexpsGroup?, source: String, url: String, - toLowerCase: Boolean = false + toLowerCase: Boolean = false, ): List { if (regexpsGroup == null || regexpsGroup.regexps.isEmpty()) return emptyList() @@ -680,7 +680,7 @@ class Parser( customRegex: CustomRegex?, source: String, url: String, - toLowerCase: Boolean = false + toLowerCase: Boolean = false, ): List { return if (customRegex != null) parseList(listOf(customRegex), source, url, toLowerCase) else emptyList() @@ -690,12 +690,30 @@ class Parser( parser: DirectParser, regexes: PluginRegexes, source: String, - url: String + url: String, ): List { val directItems = mutableListOf() + if (parser.entryClass == null && parser.entryTag == null) { + Timber.e("Entry class or tag is required for direct parsing") + return directItems + } + val doc: Document = Jsoup.parse(source) - val entries: Elements = doc.getElementsByClass(parser.entryClass) + + val containerClass: Element? = + if (parser.className != null) doc.getElementsByClass(parser.className).firstOrNull() + else if (parser.idName != null) doc.getElementById(parser.idName) else null + val entries: Elements = Elements() + if (containerClass != null) { + if (parser.entryClass != null) + entries.addAll(containerClass.getElementsByClass(parser.entryClass)) + else entries.addAll(containerClass.getElementsByTag(parser.entryTag)) + } else { + if (parser.entryClass != null) entries.addAll(doc.getElementsByClass(parser.entryClass)) + else entries.addAll(doc.getElementsByTag(parser.entryTag)) + } + for (entry in entries) { // val wholeText = entry.wholeText() // val data = entry.data() @@ -729,7 +747,7 @@ class Parser( magnets = magnets, torrents = torrents, hosting = hosting, - isCached = false + isCached = false, ) ) } @@ -780,8 +798,9 @@ class Parser( * - 2.3: added optional table index to table parsers (for tables with no specific class/id) * - 2.4: added more categories * - 2.5: parse added date + * - 2.6: added entry tag to direct parsing mode (this or entry class is required) */ - const val PLUGIN_ENGINE_VERSION: Float = 2.5f + const val PLUGIN_ENGINE_VERSION: Float = 2.6f } } @@ -795,14 +814,10 @@ sealed class ParserResult { data object MissingQuery : ParserResult() - data object MissingCategory : ParserResult() - data object NetworkBodyError : ParserResult() data object EmptyInnerLinks : ParserResult() - data object PluginBuildError : ParserResult() - data object MissingImplementationError : ParserResult() // search flow 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 10caf3bf5..c0af761f5 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 @@ -18,7 +18,7 @@ data class Plugin( @Json(name = "author") val author: String?, @Json(name = "supported_categories") val supportedCategories: SupportedCategories, @Json(name = "search") val search: PluginSearch, - @Json(name = "download") val download: PluginDownload + @Json(name = "download") val download: PluginDownload, ) : Parcelable { fun isCompatible(): Boolean { return engineVersion.toInt() == Parser.PLUGIN_ENGINE_VERSION.toInt() && @@ -26,7 +26,7 @@ data class Plugin( } } -fun isCompatible(engineVersion: Double): Boolean { +fun isCompatible(engineVersion: Float): Boolean { return engineVersion.toInt() == Parser.PLUGIN_ENGINE_VERSION.toInt() && Parser.PLUGIN_ENGINE_VERSION >= engineVersion } @@ -46,7 +46,7 @@ data class SupportedCategories( @Json(name = "videos") val videos: String?, @Json(name = "music") val music: String?, @Json(name = "tv") val tv: String?, - @Json(name = "books") val books: String? + @Json(name = "books") val books: String?, ) : Parcelable @JsonClass(generateAdapter = true) @@ -84,14 +84,14 @@ data class PluginDownload( @Json(name = "direct") val directParser: DirectParser?, @Json(name = "table_direct") val tableLink: TableParser?, @Json(name = "table_indirect") val indirectTableLink: TableParser?, - @Json(name = "regexes") val regexes: PluginRegexes + @Json(name = "regexes") val regexes: PluginRegexes, ) : Parcelable @JsonClass(generateAdapter = true) @Parcelize data class RegexpsGroup( @Json(name = "regex_use") val regexUse: String = "first", - @Json(name = "regexps") val regexps: List + @Json(name = "regexps") val regexps: List, ) : Parcelable @JsonClass(generateAdapter = true) @@ -100,7 +100,7 @@ data class CustomRegex( @Json(name = "regex") val regex: String, @Json(name = "group") val group: Int = 1, @Json(name = "slug_type") val slugType: String = "complete", - @Json(name = "other") val other: String? + @Json(name = "other") val other: String?, ) : Parcelable @JsonClass(generateAdapter = true) @@ -117,7 +117,7 @@ data class TableParser( @Json(name = "class") val className: String?, @Json(name = "id") val idName: String?, @Json(name = "index") val index: Int?, - @Json(name = "columns") val columns: Columns + @Json(name = "columns") val columns: Columns, ) : Parcelable @JsonClass(generateAdapter = true) @@ -125,7 +125,8 @@ data class TableParser( data class DirectParser( @Json(name = "class") val className: String?, @Json(name = "id") val idName: String?, - @Json(name = "entry-class") val entryClass: String + @Json(name = "entry-class") val entryClass: String?, + @Json(name = "entry-tag") val entryTag: String?, ) : Parcelable @JsonClass(generateAdapter = true) @@ -139,7 +140,7 @@ data class PluginRegexes( @Json(name = "magnet") val magnetRegex: RegexpsGroup?, @Json(name = "torrents") val torrentRegexes: RegexpsGroup?, @Json(name = "hosting") val hostingRegexes: RegexpsGroup?, - @Json(name = "details") val detailsRegex: RegexpsGroup? + @Json(name = "details") val detailsRegex: RegexpsGroup?, ) : Parcelable @JsonClass(generateAdapter = true) @@ -153,5 +154,5 @@ data class Columns( @Json(name = "magnet_column") val magnetColumn: Int?, @Json(name = "torrent_column") val torrentColumn: Int?, @Json(name = "details_column") val detailsColumn: Int?, - @Json(name = "hosting_column") val hostingColumn: Int? + @Json(name = "hosting_column") val hostingColumn: Int?, ) : Parcelable 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 new file mode 100644 index 000000000..8e5dcd73a --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceFragment.kt @@ -0,0 +1,207 @@ +package com.github.livingwithhippos.unchained.remotedevice.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.navigation.fragment.navArgs +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.RemoteDevice +import com.github.livingwithhippos.unchained.data.local.RemoteService +import com.github.livingwithhippos.unchained.databinding.FragmentRemoteDeviceBinding +import com.github.livingwithhippos.unchained.remotedevice.viewmodel.DeviceEvent +import com.github.livingwithhippos.unchained.remotedevice.viewmodel.DeviceViewModel +import com.github.livingwithhippos.unchained.utilities.DataBindingDetailsLookup +import com.github.livingwithhippos.unchained.utilities.extension.showToast +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class RemoteDeviceFragment : UnchainedFragment(), ServiceListListener { + + private val args: RemoteDeviceFragmentArgs by navArgs() + + private val viewModel: DeviceViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val binding = FragmentRemoteDeviceBinding.inflate(inflater, container, false) + + val serviceAdapter = RemoteServiceListAdapter(this) + binding.rvServiceList.adapter = serviceAdapter + + val serviceTracker: SelectionTracker = + SelectionTracker.Builder( + "serviceListSelection", + binding.rvServiceList, + ServiceKeyProvider(serviceAdapter), + DataBindingDetailsLookup(binding.rvServiceList), + StorageStrategy.createParcelableStorage(RemoteService::class.java), + ) + .withSelectionPredicate(SelectionPredicates.createSelectAnything()) + .build() + + serviceAdapter.tracker = serviceTracker + + viewModel.deviceLiveData.observe(viewLifecycleOwner) { + when (it) { + is DeviceEvent.DeviceServices -> { + serviceAdapter.submitList(it.services) + } + is DeviceEvent.DeletedDeviceServices -> { + args.item?.let { device -> viewModel.fetchDeviceServices(device.id) } + } + is DeviceEvent.Device -> { + if (args.item == null) { + val action = + RemoteDeviceFragmentDirections.actionRemoteDeviceFragmentSelf(it.device) + findNavController().navigate(action) + } else { + context?.showToast(R.string.updated) + } + } + else -> {} + } + } + + val item: RemoteDevice? = args.item + + if (item == null) { + // new device + } else { + // edit device + binding.bSaveDevice.text = getString(R.string.update) + + binding.tiName.setText(item.name) + binding.tiAddress.setText(item.address) + binding.switchDefault.isChecked = item.isDefault + + viewModel.fetchDeviceServices(item.id) + } + + binding.fabDeviceAction.setOnClickListener { showMenu(it, R.menu.device_page_action) } + + binding.bDeleteDevice.setOnClickListener { + if (item != null) { + showDeleteDeviceConfirmationDialog(item.id) + } + } + + binding.bSaveDevice.setOnClickListener { + val name = binding.tiName.text.toString().trim() + val address = binding.tiAddress.text.toString().trim() + if (name.isBlank() || address.isBlank()) { + context?.showToast(R.string.missing_parameter) + } else { + val updatedDevice = + if (item == null) { + // new device + RemoteDevice( + id = 0, + name = name, + address = address, + isDefault = binding.switchDefault.isChecked, + ) + } else { + // edit device + RemoteDevice( + id = item.id, + name = name, + address = address, + isDefault = binding.switchDefault.isChecked, + ) + } + viewModel.updateDevice(updatedDevice) + } + } + + return binding.root + } + + private fun showMenu(v: View, @MenuRes menuRes: Int) { + val popup = PopupMenu(requireContext(), v) + popup.menuInflater.inflate(menuRes, popup.menu) + + if (args.item == null) { + // todo: we should allow this when creating a new device + // maybe we could just reopen this fragment and pop this from the stack passing the + // created one as argument + popup.menu.findItem(R.id.new_remote_service).isEnabled = false + popup.menu.findItem(R.id.delete_all_services).isEnabled = false + } + + popup.setOnMenuItemClickListener { menuItem: MenuItem -> + // Respond to menu item click. + when (menuItem.itemId) { + R.id.new_remote_service -> { + val action = + RemoteDeviceFragmentDirections + .actionRemoteDeviceFragmentToRemoteServiceFragment( + deviceID = args.item!!.id + ) + 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.deleteAllDeviceServices(args.item!!.id) + } + ?.setNegativeButton(R.string.no) { dialog, _ -> dialog.cancel() } + val dialog: AlertDialog? = builder?.create() + dialog?.show() + } + + private fun showDeleteDeviceConfirmationDialog(deviceID: Int) { + val builder: AlertDialog.Builder? = activity?.let { AlertDialog.Builder(it) } + builder + ?.setMessage(R.string.dialog_confirm_action) + ?.setTitle(R.string.delete) + ?.setPositiveButton(R.string.yes) { _, _ -> viewModel.deleteDevice(deviceID) } + ?.setNegativeButton(R.string.no) { dialog, _ -> dialog.cancel() } + val dialog: AlertDialog? = builder?.create() + dialog?.show() + } + + override fun onServiceClick(item: RemoteService) { + val action = + RemoteDeviceFragmentDirections.actionRemoteDeviceFragmentToRemoteServiceFragment( + item = item, + deviceID = args.item!!.id, + ) + findNavController().navigate(action) + } +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceListAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceListAdapter.kt new file mode 100644 index 000000000..d2c57ddf1 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceListAdapter.kt @@ -0,0 +1,41 @@ +package com.github.livingwithhippos.unchained.remotedevice.view + +import androidx.recyclerview.selection.ItemKeyProvider +import androidx.recyclerview.widget.DiffUtil +import com.github.livingwithhippos.unchained.R +import com.github.livingwithhippos.unchained.data.local.RemoteDevice +import com.github.livingwithhippos.unchained.utilities.DataBindingTrackedAdapter + +class RemoteDeviceListAdapter(listener: DeviceListListener) : + DataBindingTrackedAdapter(DiffCallback(), listener) { + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: RemoteDevice, newItem: RemoteDevice): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: RemoteDevice, newItem: RemoteDevice): Boolean { + return oldItem.isDefault == newItem.isDefault && + oldItem.name == newItem.name && + oldItem.services == newItem.services && + oldItem.address == newItem.address + } + } + + override fun getItemViewType(position: Int) = R.layout.item_list_remote_device + + fun getDevice(position: Int): RemoteDevice? { + return super.getItem(position) + } + + fun getPosition(id: Int) = currentList.indexOfFirst { it.id == id } +} + +class DeviceKeyProvider(private val adapter: RemoteDeviceListAdapter) : + ItemKeyProvider(SCOPE_MAPPED) { + override fun getKey(position: Int): RemoteDevice? = adapter.getDevice(position) + + override fun getPosition(key: RemoteDevice): Int = adapter.getPosition(key.id) +} + +interface DeviceListListener { + fun onDeviceClick(item: RemoteDevice) +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceListFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceListFragment.kt new file mode 100644 index 000000000..afb6b8eea --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceListFragment.kt @@ -0,0 +1,141 @@ +package com.github.livingwithhippos.unchained.remotedevice.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.RemoteDevice +import com.github.livingwithhippos.unchained.databinding.FragmentRemoteDeviceListBinding +import com.github.livingwithhippos.unchained.remotedevice.viewmodel.DeviceEvent +import com.github.livingwithhippos.unchained.remotedevice.viewmodel.DeviceViewModel +import com.github.livingwithhippos.unchained.utilities.DataBindingDetailsLookup +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class RemoteDeviceListFragment : UnchainedFragment(), DeviceListListener { + + private val viewModel: DeviceViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val binding = FragmentRemoteDeviceListBinding.inflate(inflater, container, false) + + val deviceAdapter = RemoteDeviceListAdapter(this) + binding.rvDeviceList.adapter = deviceAdapter + + val deviceTracker: SelectionTracker = + SelectionTracker.Builder( + "deviceListSelection", + binding.rvDeviceList, + DeviceKeyProvider(deviceAdapter), + DataBindingDetailsLookup(binding.rvDeviceList), + StorageStrategy.createParcelableStorage(RemoteDevice::class.java), + ) + .withSelectionPredicate(SelectionPredicates.createSelectAnything()) + .build() + + deviceAdapter.tracker = deviceTracker + + viewModel.deviceLiveData.observe(viewLifecycleOwner) { + when (it) { + is DeviceEvent.AllDevicesAndServices -> { + // set the services number to the key.services value + + val newDevicesList = + it.itemsMap + .mapKeys { entry -> + RemoteDevice( + entry.key.id, + entry.key.name, + entry.key.address, + entry.key.isDefault, + entry.value.size, + ) + } + .keys + .toList() + + deviceAdapter.submitList(newDevicesList) + + binding.devicesStat.setContent(it.itemsMap.size.toString()) + binding.servicesStat.setContent( + it.itemsMap.values.sumOf { serv -> serv.size }.toString() + ) + } + is DeviceEvent.AllDevices -> deviceAdapter.submitList(it.devices) + is DeviceEvent.DeletedAll -> viewModel.fetchRemoteDevices() + else -> {} + } + } + + viewModel.fetchDevicesAndServices() + + binding.fabDevicesAction.setOnClickListener { showMenu(it, R.menu.devices_list_action) } + + return binding.root + } + + 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_device -> { + val action = + RemoteDeviceListFragmentDirections + .actionRemoteDeviceListFragmentToRemoteDeviceFragment() + findNavController().navigate(action) + true + } + R.id.delete_all_devices -> { + showConfirmationDialog() + true + } + else -> { + false + } + } + } + + popup.setOnDismissListener { + // Respond to popup being dismissed. + } + // Show the popup menu. + popup.show() + } + + private fun showConfirmationDialog() { + 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.deleteAllDevices() } + ?.setNegativeButton(R.string.no) { dialog, _ -> dialog.cancel() } + val dialog: AlertDialog? = builder?.create() + dialog?.show() + } + + override fun onDeviceClick(item: RemoteDevice) { + val action = + RemoteDeviceListFragmentDirections.actionRemoteDeviceListFragmentToRemoteDeviceFragment( + 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 new file mode 100644 index 000000000..3d51cdc44 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt @@ -0,0 +1,227 @@ +package com.github.livingwithhippos.unchained.remotedevice.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.RemoteService +import com.github.livingwithhippos.unchained.data.local.RemoteServiceType +import com.github.livingwithhippos.unchained.data.local.serviceTypeMap +import com.github.livingwithhippos.unchained.databinding.FragmentRemoteServiceBinding +import com.github.livingwithhippos.unchained.remotedevice.viewmodel.DeviceEvent +import com.github.livingwithhippos.unchained.remotedevice.viewmodel.DeviceViewModel +import com.github.livingwithhippos.unchained.utilities.extension.showToast +import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber + +@AndroidEntryPoint +class RemoteServiceFragment : Fragment() { + + private val args: RemoteServiceFragmentArgs by navArgs() + + private val viewModel: DeviceViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val binding = FragmentRemoteServiceBinding.inflate(inflater, container, false) + + val item: RemoteService? = 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.tiPort.setText(item.port.toString()) + binding.tiUsername.setText(item.username ?: "") + binding.tiPassword.setText(item.password.toString()) + binding.switchDefault.isChecked = item.isDefault + + setupServiceType(binding, item.type, serviceTypeView) + } + + serviceTypeView.setOnItemClickListener { _, _, position, _ -> + val selectedService = getServiceType(serviceTypeAdapter.getItem(position)) + if (selectedService != null) { + setupServiceType(binding, selectedService.value) + } + } + + 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 serviceId = item?.id ?: 0 + + if (name.isBlank() || port == null) { + context?.showToast(R.string.missing_parameter) + return@setOnClickListener + } + + when (val serviceType = getServiceType(binding.servicePickerText.text.toString())) { + RemoteServiceType.JACKETT -> { + val remoteService = + RemoteService( + id = serviceId, + device = args.deviceID, + name = name, + port = port, + username = username.ifBlank { null }, + password = password.ifBlank { null }, + type = serviceType.value, + apiToken = binding.tiApiToken.text.toString().trim(), + isDefault = false, + ) + viewModel.updateService(remoteService) + } + RemoteServiceType.KODI -> { + val remoteService = + RemoteService( + id = serviceId, + device = args.deviceID, + name = name, + port = port, + username = username.ifBlank { null }, + password = password.ifBlank { null }, + type = serviceType.value, + isDefault = binding.switchDefault.isChecked, + ) + viewModel.updateService(remoteService) + } + RemoteServiceType.VLC -> { + val remoteService = + RemoteService( + id = serviceId, + device = args.deviceID, + name = name, + port = port, + 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.deviceLiveData.observe(viewLifecycleOwner) { + when (it) { + is DeviceEvent.Service -> { + if (args.item == null) { + val action = + RemoteServiceFragmentDirections.actionRemoteServiceFragmentSelf( + item = it.service, + deviceID = args.deviceID, + ) + findNavController().navigate(action) + } else { + context?.showToast(R.string.updated) + } + } + is DeviceEvent.DeletedService -> { + context?.showToast(R.string.service_deleted) + // go back + findNavController().popBackStack() + } + else -> {} + } + } + + return binding.root + } + + 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: FragmentRemoteServiceBinding, + type: Int, + serviceDropdown: AutoCompleteTextView? = null, + ) { + if (args.item == null) { + binding.bDeleteService.isEnabled = false + } + 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 + } + 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/remotedevice/view/RemoteServiceListAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceListAdapter.kt new file mode 100644 index 000000000..24a5f69ea --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceListAdapter.kt @@ -0,0 +1,41 @@ +package com.github.livingwithhippos.unchained.remotedevice.view + +import androidx.recyclerview.selection.ItemKeyProvider +import androidx.recyclerview.widget.DiffUtil +import com.github.livingwithhippos.unchained.R +import com.github.livingwithhippos.unchained.data.local.RemoteService +import com.github.livingwithhippos.unchained.utilities.DataBindingTrackedAdapter + +class RemoteServiceListAdapter(listener: ServiceListListener) : + DataBindingTrackedAdapter(DiffCallback(), listener) { + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: RemoteService, newItem: RemoteService): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: RemoteService, newItem: RemoteService): Boolean { + return oldItem.isDefault == newItem.isDefault && + oldItem.name == newItem.name && + oldItem.type == newItem.type && + oldItem.port == newItem.port + } + } + + override fun getItemViewType(position: Int) = R.layout.item_list_remote_service + + fun getDevice(position: Int): RemoteService? { + return super.getItem(position) + } + + fun getPosition(id: Int) = currentList.indexOfFirst { it.id == id } +} + +class ServiceKeyProvider(private val adapter: RemoteServiceListAdapter) : + ItemKeyProvider(SCOPE_MAPPED) { + override fun getKey(position: Int): RemoteService? = adapter.getDevice(position) + + override fun getPosition(key: RemoteService): Int = adapter.getPosition(key.id) +} + +interface ServiceListListener { + fun onServiceClick(item: RemoteService) +} 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 new file mode 100644 index 000000000..91b0b2a2e --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/viewmodel/DeviceViewModel.kt @@ -0,0 +1,121 @@ +package com.github.livingwithhippos.unchained.remotedevice.viewmodel + +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.repository.RemoteDeviceRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.launch +import timber.log.Timber + +@HiltViewModel +class DeviceViewModel @Inject constructor(private val deviceRepository: RemoteDeviceRepository) : + ViewModel() { + + val deviceLiveData = MutableLiveData() + + fun fetchRemoteDevices() { + viewModelScope.launch { + deviceLiveData.postValue(DeviceEvent.AllDevices(deviceRepository.getAllDevices())) + } + } + + fun fetchDeviceServices(deviceId: Int) { + viewModelScope.launch { + deviceLiveData.postValue( + DeviceEvent.DeviceServices(deviceId, deviceRepository.getDeviceServices(deviceId)) + ) + } + } + + fun updateDevice(device: RemoteDevice) { + viewModelScope.launch { + val insertedRow = deviceRepository.upsertDevice(device) + val deviceID = deviceRepository.getDeviceIDByRow(insertedRow) + // if the default device is updated, remove the old preference + if (deviceID != null) { + if (device.isDefault) { + deviceRepository.setDefaultDevice(deviceID) + } + val newDevice = deviceRepository.getDevice(deviceID) + if (newDevice != null) deviceLiveData.postValue(DeviceEvent.Device(newDevice)) + } + } + } + + fun updateService(remoteService: RemoteService) { + viewModelScope.launch { + val insertedRow = deviceRepository.insertService(remoteService) + val serviceID = deviceRepository.getServiceIDByRow(insertedRow) + if (serviceID != null) { + if (remoteService.isDefault) { + deviceRepository.setDefaultDeviceService(remoteService.device, serviceID) + } + val newService = deviceRepository.getService(serviceID) + if (newService != null) deviceLiveData.postValue(DeviceEvent.Service(newService)) + } else { + Timber.e("Service ID is null trying to save on the db") + } + } + } + + fun deleteService(service: RemoteService) { + viewModelScope.launch { + deviceRepository.deleteService(service) + deviceLiveData.postValue(DeviceEvent.DeletedService(service)) + } + } + + fun deleteAllDeviceServices(deviceId: Int) { + viewModelScope.launch { + deviceRepository.deleteAllDeviceServices(deviceId) + deviceLiveData.postValue(DeviceEvent.DeletedDeviceServices(deviceId)) + } + } + + fun deleteDevice(deviceId: Int) { + viewModelScope.launch { + deviceRepository.deleteDevice(deviceId) + deviceLiveData.postValue(DeviceEvent.DeletedDevice) + } + } + + fun deleteAllDevices() { + viewModelScope.launch { + deviceRepository.deleteAll() + deviceLiveData.postValue(DeviceEvent.DeletedAll) + } + } + + fun fetchDevicesAndServices() { + viewModelScope.launch { + deviceLiveData.postValue( + DeviceEvent.AllDevicesAndServices(deviceRepository.getDevicesAndServices()) + ) + } + } +} + +sealed class DeviceEvent { + data object DeletedAll : DeviceEvent() + + data object DeletedDevice : DeviceEvent() + + data class AllDevicesAndServices(val itemsMap: Map>) : + DeviceEvent() + + data class Device(val device: RemoteDevice) : DeviceEvent() + + data class AllDevices(val devices: List) : DeviceEvent() + + data class DeviceServices(val deviceId: Int, val services: List) : DeviceEvent() + + data class DeletedDeviceServices(val deviceId: Int) : DeviceEvent() + + data class Service(val service: RemoteService) : DeviceEvent() + + data class DeletedService(val service: RemoteService) : DeviceEvent() +} 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 c3750dc72..1100f9067 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 @@ -9,18 +9,18 @@ data class JsonPluginRepository( @Json(name = "name") val name: String, @Json(name = "description") val description: String, @Json(name = "author") val author: String, - @Json(name = "plugins") val plugins: List + @Json(name = "plugins") val plugins: List, ) @JsonClass(generateAdapter = true) data class JsonPlugin( @Json(name = "id") val id: String, - @Json(name = "versions") val versions: List + @Json(name = "versions") val versions: List, ) @JsonClass(generateAdapter = true) data class JsonPluginVersion( @Json(name = "plugin") val plugin: Float, - @Json(name = "engine") val engine: Double, - @Json(name = "link") val link: String + @Json(name = "engine") val engine: Float, + @Json(name = "link") val link: String, ) 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 ac8e96afa..b8c8efcec 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 @@ -14,7 +14,7 @@ sealed class RepositoryListItem { val name: String, val version: Double, val description: String, - val author: String + val author: String, ) : RepositoryListItem(), Parcelable data class Plugin( @@ -26,7 +26,7 @@ sealed class RepositoryListItem { val author: String?, // see PluginStatus var status: String, - var statusTranslation: String + var statusTranslation: String, ) : RepositoryListItem() } @@ -64,7 +64,7 @@ class PluginRepositoryAdapter(listener: PluginListener) : class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: RepositoryListItem, - newItem: RepositoryListItem + newItem: RepositoryListItem, ): Boolean { // trick for smart casting if ( @@ -81,7 +81,7 @@ class PluginRepositoryAdapter(listener: PluginListener) : override fun areContentsTheSame( oldItem: RepositoryListItem, - newItem: RepositoryListItem + newItem: RepositoryListItem, ): Boolean { // trick for smart casting if ( diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/AddRepositoryDialogFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/AddRepositoryDialogFragment.kt index d7d0154d6..c2d31f0ab 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/AddRepositoryDialogFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/AddRepositoryDialogFragment.kt @@ -67,7 +67,7 @@ class AddRepositoryDialogFragment : DialogFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View? { viewModel.pluginsRepositoryLiveData.observe(this) { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/ManageRepositoryDialogFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/ManageRepositoryDialogFragment.kt index ff106df82..b18aacc5c 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/ManageRepositoryDialogFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/ManageRepositoryDialogFragment.kt @@ -86,7 +86,7 @@ class ManageRepositoryDialogFragment : DialogFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View? { viewModel.pluginsRepositoryLiveData.observe(this) { 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 7e4a722e0..9c8d87f27 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 @@ -38,7 +38,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { val binding = FragmentRepositoryBinding.inflate(inflater, container, false) @@ -107,7 +107,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { getString( R.string.plugins_install_results_format, failures, - success + success, ) ) } @@ -117,7 +117,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { // do nothing, these are for dialogs } } - } + }, ) binding.progressBar.isIndeterminate = true @@ -142,7 +142,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { private fun updateList( adapter: PluginRepositoryAdapter, data: Map>>, - installedData: LocalPlugins + installedData: LocalPlugins, ) { // todo: accept only https links when adding repositories val plugins = mutableListOf() @@ -173,8 +173,8 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { PluginVersion( repository = repository.key.link, plugin = plug.key.name, - version = 0F, - engine = 0.0, + version = 0f, + engine = 0.0f, link = repository.key.link, ) } else { @@ -188,7 +188,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { val pickedStatus = when { isCompatible(pickedVersion.engine) -> PluginStatus.isNew - pickedVersion.engine == 0.0 -> PluginStatus.unknown + pickedVersion.engine == 0.0f -> PluginStatus.unknown else -> PluginStatus.incompatible } @@ -222,8 +222,8 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { PluginVersion( repository = repository.key.link, plugin = onlinePlugin.key.name, - version = 0F, - engine = 0.0, + version = 0f, + engine = 0.0f, link = repository.key.link, ) } else { @@ -234,7 +234,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { repository = repository.key.link, plugin = onlinePlugin.key.name, version = installedPlugin.version, - engine = 0.0, + engine = 0.0f, link = repository.key.link, ) } @@ -242,13 +242,13 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { val pickedStatus = when { isCompatible(pickedVersion.engine) -> PluginStatus.isNew - pickedVersion.engine == 0.0 -> PluginStatus.unknown + pickedVersion.engine == 0.0f -> PluginStatus.unknown else -> PluginStatus.incompatible } getPluginItemFromVersion( pickedVersion, pickedStatus, - repository.key.author + repository.key.author, ) } else { // at least a version from the repo is available, check compatibility @@ -262,14 +262,14 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { getPluginItemFromVersion( latestVersion, PluginStatus.incompatible, - null + null, ) } else { // latest compatible version getPluginItemFromVersion( latestCompatibleVersion, PluginStatus.isNew, - repository.key.author + repository.key.author, ) } } else { @@ -281,20 +281,20 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { getPluginItemFromVersion( latestVersion, PluginStatus.hasIncompatibleUpdate, - repository.key.author + repository.key.author, ) } else { if (latestCompatibleVersion.version > installedPlugin.version) { getPluginItemFromVersion( latestCompatibleVersion, PluginStatus.hasUpdate, - repository.key.author + repository.key.author, ) } else { getPluginItemFromVersion( latestCompatibleVersion, PluginStatus.updated, - repository.key.author + repository.key.author, ) } } @@ -328,7 +328,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { link = MANUAL_PLUGINS_REPOSITORY_NAME, author = it.author, status = currentStatus, - statusTranslation = getStatusTranslation(currentStatus) + statusTranslation = getStatusTranslation(currentStatus), ) } ) @@ -342,7 +342,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { private fun getPluginItemFromVersion( pluginVersion: PluginVersion, pluginStatus: String, - author: String? + author: String?, ): RepositoryListItem.Plugin { return RepositoryListItem.Plugin( repository = pluginVersion.repository, @@ -351,7 +351,7 @@ class RepositoryFragment : UnchainedFragment(), PluginListener { link = pluginVersion.link, author = author, status = pluginStatus, - statusTranslation = getStatusTranslation(pluginStatus) + statusTranslation = getStatusTranslation(pluginStatus), ) } 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 2e5d9a4c1..498f4aafa 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 @@ -254,16 +254,16 @@ sealed class PluginRepositoryEvent { data class MultipleInstallation( val downloadErrors: Int, - val installResults: List + val installResults: List, ) : PluginRepositoryEvent() data class Uninstalled(val quantity: Int) : PluginRepositoryEvent() - object Updated : PluginRepositoryEvent() + data object Updated : PluginRepositoryEvent() data class FullData( val dbData: Map>>, - val installedData: LocalPlugins + val installedData: LocalPlugins, ) : PluginRepositoryEvent() data class InvalidRepositoryLink(val reason: InvalidLinkReason) : PluginRepositoryEvent() @@ -272,9 +272,9 @@ sealed class PluginRepositoryEvent { } sealed class InvalidLinkReason { - object NotAnUrl : InvalidLinkReason() + data object NotAnUrl : InvalidLinkReason() - object ConnectionError : InvalidLinkReason() + data object ConnectionError : InvalidLinkReason() - object ParsingError : InvalidLinkReason() + data object ParsingError : InvalidLinkReason() } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/model/LinkItemAdapter.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/model/LinkItemAdapter.kt index 9f2a96e3f..f6ba37be6 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/model/LinkItemAdapter.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/model/LinkItemAdapter.kt @@ -28,5 +28,5 @@ data class LinkItem( val type: String, val name: String, val link: String, - var cached: Boolean = false + var cached: Boolean = false, ) 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 new file mode 100644 index 000000000..c67009537 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/PluginSearchFragment.kt @@ -0,0 +1,215 @@ +package com.github.livingwithhippos.unchained.search.view + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AutoCompleteTextView +import android.widget.Button +import androidx.fragment.app.viewModels +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.search.viewmodel.SearchViewModel +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 timber.log.Timber + +@AndroidEntryPoint +class PluginSearchFragment : UnchainedFragment() { + + private val viewModel: SearchViewModel by viewModels() + + private val pluginsList: MutableList = mutableListOf() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val binding = FragmentSearchPluginsTabBinding.inflate(inflater, container, false) + + setup(binding) + + viewModel.pluginLiveData.observe(viewLifecycleOwner) { parsedPlugins -> + if (pluginsList.isNotEmpty()) pluginsList.clear() + pluginsList.addAll(parsedPlugins.first) + setupAndShowSheet(inflater, parsedPlugins.first) + } + + return binding.root + } + + private fun setupAndShowSheet(inflater: LayoutInflater, plugins: List) { + val sideSheetDialog = SideSheetDialog(requireContext()) + sideSheetDialog.setContentView(R.layout.sidesheet_search_plugins_options) + + sideSheetDialog.findViewById