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/)
@@ -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