diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..5f81826f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.idea
+.gradle
+build/
\ No newline at end of file
diff --git a/README.md b/README.md
index 96472c71..aff118d1 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,37 @@
-# android-airpods-companion
\ No newline at end of file
+# Companion App for AirPods (CAP)
+
+A companion app that adds support for AirPod specific features to Android.
+
+Supported models:
+
+* AirPods Gen1
+* AirPods Gen2
+* AirPods Pro
+
+## Support the project
+
+* Buy the CAP Pro In-App purchase on [Google Play](https://play.google.com/store/apps/details?id=eu.darken.cap)
+* [Buy me a coffee](https://www.buymeacoffee.com/tydarken)
+* Help translate CAP [on Crowdin](https://crowdin.com/project/airpod-companion)
+
+## Download
+
+* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.cap)
+* [GitHub](https://github.com/d4rken/android-airpods-companion/releases/latest)
+
+## Get help
+
+* [Github Issues](https://github.com/d4rken/android-airpods-companion/issues)
+* [Discord](https://discord.gg/vHubYPp)
+
+## Screenshots
+
+## License
+
+CAP's code is available under a GPL v3 license, this excludes:
+
+* CAP icons, logos, mascots and marketing materials.
+* CAP animations and videos.
+* CAP documentation.
+* Google Play store screenshots.
+* Google Play store texts & descriptions.
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 00000000..71f5651d
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,230 @@
+plugins {
+ id 'com.android.application'
+ id 'kotlin-android'
+ id 'kotlin-kapt'
+ id 'kotlin-parcelize'
+ id 'androidx.navigation.safeargs.kotlin'
+ id 'com.bugsnag.android.gradle'
+ id 'dagger.hilt.android.plugin'
+}
+
+def gitSha = 'git rev-parse --short HEAD'.execute([], project.rootDir).text.trim()
+def buildTime = new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("GMT+1"))
+
+android {
+ def packageName = "eu.darken.cap"
+
+ compileSdkVersion buildConfig.compileSdk
+
+ defaultConfig {
+ applicationId "${packageName}"
+
+ minSdkVersion buildConfig.minSdk
+ targetSdkVersion buildConfig.targetSdk
+
+ versionCode buildConfig.version.code
+ versionName buildConfig.version.name
+
+ testInstrumentationRunner "eu.darken.cap.HiltTestRunner"
+
+ buildConfigField "String", "GITSHA", "\"${gitSha}\""
+ buildConfigField "String", "BUILDTIME", "\"${buildTime}\""
+ }
+
+ signingConfigs {
+ release {}
+ }
+ def signingPropFile = new File(System.properties['user.home'], ".appconfig/${packageName}/signing.properties")
+ if (signingPropFile.canRead()) {
+ Properties signingProps = new Properties()
+ signingProps.load(new FileInputStream(signingPropFile))
+ signingConfigs {
+ release {
+ storeFile new File(signingProps['release.storePath'])
+ keyAlias signingProps['release.keyAlias']
+ storePassword signingProps['release.storePassword']
+ keyPassword signingProps['release.keyPassword']
+ }
+ }
+ }
+
+ buildTypes {
+ def proguardRulesRelease = fileTree(dir: "../proguard", include: ["*.pro"]).asList().toArray()
+ debug {
+ ext.enableBugsnag = false
+ minifyEnabled false
+ shrinkResources false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
+ proguardFiles proguardRulesRelease
+ proguardFiles 'proguard-rules-debug.pro'
+ }
+ release {
+ signingConfig signingConfigs.release
+ lintOptions {
+ abortOnError true
+ fatal 'StopShip'
+ }
+ ext.enableBugsnag = true
+ minifyEnabled true
+ shrinkResources true
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
+ proguardFiles proguardRulesRelease
+ }
+ applicationVariants.all { variant ->
+ if (variant.buildType.name == "debug") {
+ variant.mergedFlavor.resourceConfigurations.clear()
+ variant.mergedFlavor.resourceConfigurations.add("en")
+ variant.mergedFlavor.resourceConfigurations.add("de")
+ } else if (variant.buildType.name != "debug") {
+ variant.outputs.each { output ->
+ output.outputFileName = "${packageName}-v" + defaultConfig.versionName + "(" + defaultConfig.versionCode + ")-" + variant.buildType.name.toUpperCase() + "-" + gitSha + ".apk"
+ }
+ }
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+
+ buildFeatures {
+ viewBinding true
+ }
+
+ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
+ kotlinOptions {
+ jvmTarget = "1.8"
+
+ freeCompilerArgs += [
+ "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi",
+ "-Xuse-experimental=kotlinx.coroutines.FlowPreview",
+ "-Xuse-experimental=kotlin.time.ExperimentalTime",
+ "-Xuse-experimental=kotlin.ExperimentalUnsignedTypes",
+ "-Xopt-in=kotlin.RequiresOptIn"
+ ]
+ }
+ }
+
+ testOptions {
+ unitTests.all {
+ useJUnitPlatform()
+ }
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
+
+ sourceSets {
+ test {
+ java.srcDirs += "$projectDir/src/testShared/java"
+ }
+ androidTest {
+ java.srcDirs += "$projectDir/src/testShared/java"
+ androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
+ }
+ }
+}
+
+dependencies {
+ // Kotlin
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:${versions.kotlin.core}"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.kotlin.coroutines}"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.kotlin.coroutines}"
+
+ testImplementation "org.jetbrains.kotlin:kotlin-reflect:${versions.kotlin.core}"
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:${versions.kotlin.coroutines}"
+ androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${versions.kotlin.coroutines}") {
+ // conflicts with mockito due to direct inclusion of byte buddy
+ exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug"
+ }
+
+ // Debugging
+ implementation ('com.bugsnag:bugsnag-android:5.9.2')
+ implementation 'com.getkeepsafe.relinker:relinker:1.4.3'
+
+ // DI
+ implementation "com.google.dagger:dagger:${versions.dagger.core}"
+ implementation "com.google.dagger:dagger-android:${versions.dagger.core}"
+
+ kapt "com.google.dagger:dagger-compiler:${versions.dagger.core}"
+ kapt "com.google.dagger:dagger-android-processor:${versions.dagger.core}"
+
+ implementation "com.google.dagger:hilt-android:${versions.dagger.core}"
+ kapt "com.google.dagger:hilt-android-compiler:${versions.dagger.core}"
+
+ testImplementation "com.google.dagger:hilt-android-testing:${versions.dagger.core}"
+ kaptTest "com.google.dagger:hilt-android-compiler:${versions.dagger.core}"
+
+ androidTestImplementation "com.google.dagger:hilt-android-testing:${versions.dagger.core}"
+ kaptAndroidTest "com.google.dagger:hilt-android-compiler:${versions.dagger.core}"
+
+ kapt "androidx.hilt:hilt-compiler:1.0.0"
+ implementation 'androidx.hilt:hilt-common:1.0.0'
+
+ // Support libs
+ implementation 'androidx.core:core-ktx:1.7.0'
+ implementation 'androidx.appcompat:appcompat:1.4.0'
+ implementation 'androidx.annotation:annotation:1.3.0'
+
+ implementation 'androidx.activity:activity-ktx:1.4.0'
+ implementation 'androidx.fragment:fragment-ktx:1.4.0'
+
+ implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.4.0'
+ implementation 'androidx.lifecycle:lifecycle-common-java8:2.4.0'
+ implementation 'androidx.lifecycle:lifecycle-process:2.4.0'
+ implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'
+
+ implementation "androidx.navigation:navigation-fragment-ktx:2.3.5"
+ implementation "androidx.navigation:navigation-ui-ktx:2.3.5"
+
+ def work_version = "2.7.1"
+ implementation "androidx.work:work-runtime:${work_version}"
+ testImplementation "androidx.work:work-testing:${work_version}"
+ implementation "androidx.work:work-runtime-ktx:${work_version}"
+ implementation 'androidx.hilt:hilt-work:1.0.0'
+
+ // UI
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
+ implementation 'com.google.android.material:material:1.6.0-alpha01'
+
+ // Testing
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation "org.junit.vintage:junit-vintage-engine:5.7.1"
+ testImplementation "androidx.test:core-ktx:1.4.0"
+
+ testImplementation "io.mockk:mockk:1.12.1"
+ androidTestImplementation "io.mockk:mockk-android:1.11.0"
+
+ testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.7.1"
+ testImplementation "org.junit.jupiter:junit-jupiter-api:5.7.1"
+ testImplementation "org.junit.jupiter:junit-jupiter-params:5.7.1"
+
+ androidTestImplementation "androidx.navigation:navigation-testing:2.3.5"
+
+ testImplementation "io.kotest:kotest-runner-junit5:4.6.2"
+ testImplementation "io.kotest:kotest-assertions-core-jvm:4.6.2"
+ testImplementation "io.kotest:kotest-property-jvm:4.6.2"
+ androidTestImplementation "io.kotest:kotest-assertions-core-jvm:4.6.2"
+ androidTestImplementation "io.kotest:kotest-property-jvm:4.6.2"
+
+ testImplementation 'android.arch.core:core-testing:1.1.1'
+ androidTestImplementation 'android.arch.core:core-testing:1.1.1'
+ debugImplementation 'androidx.test:core-ktx:1.4.0'
+
+ androidTestImplementation 'androidx.test.ext:junit:1.1.3'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
+
+ androidTestImplementation 'androidx.test:runner:1.4.0'
+ androidTestImplementation 'androidx.test:rules:1.4.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0'
+ androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.4.0'
+}
\ No newline at end of file
diff --git a/app/proguard-rules-debug.pro b/app/proguard-rules-debug.pro
new file mode 100644
index 00000000..0674e774
--- /dev/null
+++ b/app/proguard-rules-debug.pro
@@ -0,0 +1 @@
+-dontobfuscate
\ No newline at end of file
diff --git a/app/proguard/proguard-rules.pro b/app/proguard/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/app/proguard/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..dfe72160
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/App.kt b/app/src/main/java/eu/darken/cap/App.kt
new file mode 100644
index 00000000..2e38a57f
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/App.kt
@@ -0,0 +1,49 @@
+package eu.darken.cap
+
+import android.app.Application
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
+import com.getkeepsafe.relinker.ReLinker
+import dagger.hilt.android.HiltAndroidApp
+import eu.darken.cap.bugreporting.BugReporter
+import eu.darken.cap.common.coroutine.AppScope
+import eu.darken.cap.common.debug.logging.*
+import eu.darken.cap.monitor.core.worker.MonitorControl
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltAndroidApp
+open class App : Application(), Configuration.Provider {
+
+ @Inject lateinit var workerFactory: HiltWorkerFactory
+ @Inject lateinit var bugReporter: BugReporter
+ @Inject lateinit var monitorControl: MonitorControl
+ @Inject @AppScope lateinit var appScope: CoroutineScope
+
+ override fun onCreate() {
+ super.onCreate()
+ if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
+
+ ReLinker
+ .log { message -> log(TAG) { "ReLinker: $message" } }
+ .loadLibrary(this, "bugsnag-plugin-android-anr")
+
+ bugReporter.setup()
+
+ log(TAG) { "onCreate() done! ${Exception().asLog()}" }
+
+ appScope.launch {
+ monitorControl.startMonitor(forceStart = true)
+ }
+ }
+
+ override fun getWorkManagerConfiguration(): Configuration = Configuration.Builder()
+ .setMinimumLoggingLevel(android.util.Log.VERBOSE)
+ .setWorkerFactory(workerFactory)
+ .build()
+
+ companion object {
+ internal val TAG = logTag("CAP")
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/bugreporting/BugReportSettings.kt b/app/src/main/java/eu/darken/cap/bugreporting/BugReportSettings.kt
new file mode 100644
index 00000000..357494ad
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/bugreporting/BugReportSettings.kt
@@ -0,0 +1,20 @@
+package eu.darken.cap.bugreporting
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import eu.darken.cap.common.preferences.createFlowPreference
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class BugReportSettings @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+
+ private val prefs by lazy {
+ context.getSharedPreferences("bugreport_settings", Context.MODE_PRIVATE)
+ }
+
+ val isEnabled = prefs.createFlowPreference("bugreport.automatic.enabled", true)
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/bugreporting/BugReporter.kt b/app/src/main/java/eu/darken/cap/bugreporting/BugReporter.kt
new file mode 100644
index 00000000..e3046110
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/bugreporting/BugReporter.kt
@@ -0,0 +1,57 @@
+package eu.darken.cap.bugreporting
+
+import android.content.Context
+import com.bugsnag.android.Bugsnag
+import com.bugsnag.android.Configuration
+import dagger.hilt.android.qualifiers.ApplicationContext
+import eu.darken.cap.common.InstallId
+import eu.darken.cap.common.debug.bugsnag.BugsnagErrorHandler
+import eu.darken.cap.common.debug.bugsnag.BugsnagLogger
+import eu.darken.cap.common.debug.bugsnag.NOPBugsnagErrorHandler
+import eu.darken.cap.common.debug.logging.Logging
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+import javax.inject.Inject
+import javax.inject.Provider
+import javax.inject.Singleton
+
+@Singleton
+class BugReporter @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val bugReportSettings: BugReportSettings,
+ private val installId: InstallId,
+ private val bugsnagLogger: Provider,
+ private val bugsnagErrorHandler: Provider,
+ private val nopBugsnagErrorHandler: Provider,
+) {
+
+ fun setup() {
+ val isEnabled = bugReportSettings.isEnabled.value
+ log(TAG) { "setup(): isEnabled=$isEnabled" }
+
+ try {
+ val bugsnagConfig = Configuration.load(context).apply {
+ if (bugReportSettings.isEnabled.value) {
+ Logging.install(bugsnagLogger.get())
+ setUser(installId.id, null, null)
+ autoTrackSessions = true
+ addOnError(bugsnagErrorHandler.get())
+ log(TAG) { "Bugsnag setup done!" }
+ } else {
+ autoTrackSessions = false
+ addOnError(nopBugsnagErrorHandler.get())
+ log(TAG) { "Installing Bugsnag NOP error handler due to user opt-out!" }
+ }
+ }
+
+ Bugsnag.start(context, bugsnagConfig)
+ Bugs.ready = true
+ } catch (e: IllegalStateException) {
+ log(TAG) { "Bugsnag API Key not configured." }
+ }
+ }
+
+ companion object {
+ private val TAG = logTag("BugReporter")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/bugreporting/Bugs.kt b/app/src/main/java/eu/darken/cap/bugreporting/Bugs.kt
new file mode 100644
index 00000000..51c43ba6
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/bugreporting/Bugs.kt
@@ -0,0 +1,21 @@
+package eu.darken.cap.bugreporting
+
+import com.bugsnag.android.Bugsnag
+import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
+import eu.darken.cap.common.debug.logging.Logging.Priority.WARN
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+
+object Bugs {
+ var ready = false
+ fun report(exception: Exception) {
+ log(TAG, VERBOSE) { "Reporting $exception" }
+ if (!ready) {
+ log(TAG, WARN) { "Bug tracking not initialized yet." }
+ return
+ }
+ Bugsnag.notify(exception)
+ }
+
+ private val TAG = logTag("Bugs")
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/BuildConfigWrap.kt b/app/src/main/java/eu/darken/cap/common/BuildConfigWrap.kt
new file mode 100644
index 00000000..697afaf7
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/BuildConfigWrap.kt
@@ -0,0 +1,16 @@
+package eu.darken.cap.common
+
+import eu.darken.cap.BuildConfig
+
+
+// Can't be const because that prevents them from being mocked in tests
+@Suppress("MayBeConstant")
+object BuildConfigWrap {
+ val APPLICATION_ID = BuildConfig.APPLICATION_ID
+ val DEBUG: Boolean = BuildConfig.DEBUG
+ val BUILD_TYPE: String = BuildConfig.BUILD_TYPE
+
+ val VERSION_CODE: Long = BuildConfig.VERSION_CODE.toLong()
+ val VERSION_NAME: String = BuildConfig.VERSION_NAME
+ val GIT_SHA: String = BuildConfig.GITSHA
+}
diff --git a/app/src/main/java/eu/darken/cap/common/BuildWrap.kt b/app/src/main/java/eu/darken/cap/common/BuildWrap.kt
new file mode 100644
index 00000000..f0051a7a
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/BuildWrap.kt
@@ -0,0 +1,16 @@
+package eu.darken.cap.common
+
+import android.os.Build
+
+// Can't be const because that prevents them from being mocked in tests
+@Suppress("MayBeConstant")
+object BuildWrap {
+
+ val VERSION = VersionWrap
+
+ object VersionWrap {
+ val SDK_INT = Build.VERSION.SDK_INT
+ }
+}
+
+fun hasApiLevel(level: Int): Boolean = BuildWrap.VERSION.SDK_INT >= level
diff --git a/app/src/main/java/eu/darken/cap/common/ByteArrayExtensions.kt b/app/src/main/java/eu/darken/cap/common/ByteArrayExtensions.kt
new file mode 100644
index 00000000..bce75756
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/ByteArrayExtensions.kt
@@ -0,0 +1,14 @@
+package eu.darken.cap.common
+
+import java.util.*
+
+fun Byte.toHex(): String = String.format("%02X", this)
+fun UByte.toHex(): String = this.toByte().toHex()
+
+val Byte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toByte()
+val Byte.lowerNibble get() = (this.toInt() and 0b1111).toByte()
+val UByte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toUByte()
+val UByte.lowerNibble get() = (this.toInt() and 0b1111).toUByte()
+
+fun Byte.isBitSet(pos: Int): Boolean = BitSet.valueOf(arrayOf(this).toByteArray()).get(pos)
+fun UByte.isBitSet(pos: Int): Boolean = this.toByte().isBitSet(pos)
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/ContextExtensions.kt b/app/src/main/java/eu/darken/cap/common/ContextExtensions.kt
new file mode 100644
index 00000000..6c045ed8
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/ContextExtensions.kt
@@ -0,0 +1,40 @@
+package eu.darken.cap.common
+
+import android.annotation.SuppressLint
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.res.TypedArray
+import androidx.annotation.AttrRes
+import androidx.annotation.ColorInt
+import androidx.annotation.ColorRes
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+
+
+@ColorInt
+fun Context.getColorForAttr(@AttrRes attrId: Int): Int {
+ var typedArray: TypedArray? = null
+ try {
+ typedArray = this.theme.obtainStyledAttributes(intArrayOf(attrId))
+ return typedArray.getColor(0, 0)
+ } finally {
+ typedArray?.recycle()
+ }
+}
+
+@ColorInt
+fun Fragment.getColorForAttr(@AttrRes attrId: Int): Int = requireContext().getColorForAttr(attrId)
+
+@ColorInt
+fun Context.getCompatColor(@ColorRes attrId: Int): Int {
+ return ContextCompat.getColor(this, attrId)
+}
+
+@ColorInt
+fun Fragment.getCompatColor(@ColorRes attrId: Int): Int = requireContext().getCompatColor(attrId)
+
+@SuppressLint("NewApi")
+fun Context.startServiceCompat(intent: Intent): ComponentName? {
+ return if (hasApiLevel(26)) startForegroundService(intent) else startService(intent)
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/InstallId.kt b/app/src/main/java/eu/darken/cap/common/InstallId.kt
new file mode 100644
index 00000000..90796ccf
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/InstallId.kt
@@ -0,0 +1,42 @@
+package eu.darken.cap.common
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+import java.io.File
+import java.util.*
+import java.util.regex.Pattern
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class InstallId @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val installIDFile = File(context.filesDir, INSTALL_ID_FILENAME)
+ val id: String by lazy {
+ val existing = if (installIDFile.exists()) {
+ installIDFile.readText().also {
+ if (!UUID_PATTERN.matcher(it).matches()) throw IllegalStateException("Invalid InstallID: $it")
+ }
+ } else {
+ null
+ }
+
+ return@lazy existing ?: UUID.randomUUID().toString().also {
+ log(TAG) { "New install ID created: $it" }
+ installIDFile.writeText(it)
+ }
+ }
+
+ companion object {
+ private val TAG: String = logTag("InstallID")
+ private val UUID_PATTERN = Pattern.compile(
+ "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
+ )
+
+ private const val INSTALL_ID_FILENAME = "installid"
+ }
+}
+
diff --git a/app/src/main/java/eu/darken/cap/common/LiveDataExtensions.kt b/app/src/main/java/eu/darken/cap/common/LiveDataExtensions.kt
new file mode 100644
index 00000000..7d4aa746
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/LiveDataExtensions.kt
@@ -0,0 +1,23 @@
+package eu.darken.cap.common
+
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.LiveData
+import androidx.viewbinding.ViewBinding
+
+
+fun LiveData.observe2(fragment: Fragment, callback: (T) -> Unit) {
+ observe(fragment.viewLifecycleOwner) { callback.invoke(it) }
+}
+
+inline fun LiveData.observe2(
+ fragment: Fragment,
+ ui: VB,
+ crossinline callback: VB.(T) -> Unit
+) {
+ observe(fragment.viewLifecycleOwner) { callback.invoke(ui, it) }
+}
+
+fun LiveData.observe2(activity: AppCompatActivity, callback: (T) -> Unit) {
+ observe(activity) { callback.invoke(it) }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/bluetooth/BleScanner.kt b/app/src/main/java/eu/darken/cap/common/bluetooth/BleScanner.kt
new file mode 100644
index 00000000..e6a9e650
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/bluetooth/BleScanner.kt
@@ -0,0 +1,68 @@
+package eu.darken.cap.common.bluetooth
+
+import android.bluetooth.BluetoothManager
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanFilter
+import android.bluetooth.le.ScanResult
+import android.bluetooth.le.ScanSettings
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import eu.darken.cap.common.debug.logging.Logging.Priority.*
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+import eu.darken.cap.pods.core.airpods.ProximityPairing
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class BleScanner @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val bluetoothManager: BluetoothManager,
+) {
+ // TODO check Bluetooth available
+ // TODO check Bluetooth enabled
+ fun scan(
+ filter: Set = ProximityPairing.getBleScanFilter(),
+ mode: Int = ScanSettings.SCAN_MODE_LOW_LATENCY,
+ delay: Long = 1,
+ ): Flow> = callbackFlow {
+ val scanner = bluetoothManager.adapter.bluetoothLeScanner
+
+ val callback = object : ScanCallback() {
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
+ log(TAG, VERBOSE) { "onScanResult(callbackType=$callbackType, result=$result)" }
+ trySend(listOf(result))
+ }
+
+ override fun onBatchScanResults(results: MutableList) {
+ log(TAG, VERBOSE) { "onBatchScanResults(results=$results)" }
+ trySend(results)
+ }
+
+ override fun onScanFailed(errorCode: Int) {
+ log(TAG, WARN) { "onScanFailed(errorCode=$errorCode)" }
+ }
+ }
+
+ val settings = ScanSettings.Builder().apply {
+ setScanMode(mode)
+ setReportDelay(delay)
+ }.build()
+
+ scanner.startScan(filter.toList(), settings, callback)
+ log(TAG, VERBOSE) { "BleScanner started (filter=$filter, settings=$settings)" }
+
+ awaitClose {
+ log(TAG, INFO) { "BleScanner stopped" }
+ scanner.stopScan(callback)
+ }
+ }
+
+
+ companion object {
+ private val TAG = logTag("Bluetooth", "BleScanner")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/bluetooth/BluetoothDevice2.kt b/app/src/main/java/eu/darken/cap/common/bluetooth/BluetoothDevice2.kt
new file mode 100644
index 00000000..2714742b
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/bluetooth/BluetoothDevice2.kt
@@ -0,0 +1,14 @@
+package eu.darken.cap.common.bluetooth
+
+import android.bluetooth.BluetoothDevice
+import android.os.ParcelUuid
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class BluetoothDevice2(
+ private val bluetoothDevice: BluetoothDevice
+) : Parcelable {
+
+ fun hasFeature(uuid: ParcelUuid): Boolean = bluetoothDevice.hasFeature(uuid)
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/bluetooth/BluetoothDeviceExtensions.kt b/app/src/main/java/eu/darken/cap/common/bluetooth/BluetoothDeviceExtensions.kt
new file mode 100644
index 00000000..fbb772e8
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/bluetooth/BluetoothDeviceExtensions.kt
@@ -0,0 +1,8 @@
+package eu.darken.cap.common.bluetooth
+
+import android.bluetooth.BluetoothDevice
+import android.os.ParcelUuid
+
+fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
+ return uuids?.contains(uuid) ?: false
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/bluetooth/BluetoothManager2.kt b/app/src/main/java/eu/darken/cap/common/bluetooth/BluetoothManager2.kt
new file mode 100644
index 00000000..036c1ddd
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/bluetooth/BluetoothManager2.kt
@@ -0,0 +1,145 @@
+package eu.darken.cap.common.bluetooth
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Handler
+import android.os.HandlerThread
+import dagger.hilt.android.qualifiers.ApplicationContext
+import eu.darken.cap.common.coroutine.DispatcherProvider
+import eu.darken.cap.common.debug.logging.Logging.Priority.*
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import java.io.IOException
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.coroutines.resume
+
+@Singleton
+class BluetoothManager2 @Inject constructor(
+ private val manager: BluetoothManager,
+ @ApplicationContext private val context: Context,
+ private val dispatcherProvider: DispatcherProvider,
+) {
+
+ val isBluetoothEnabled: Flow = callbackFlow {
+ send(manager.adapter?.isEnabled ?: false)
+
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (BluetoothAdapter.ACTION_STATE_CHANGED != intent.action) {
+ log(TAG) { "Unknown BluetoothAdapter action: $intent" }
+ return
+ }
+
+ val value = when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) {
+ BluetoothAdapter.STATE_OFF -> false
+ BluetoothAdapter.STATE_ON -> true
+ else -> false
+ }
+
+ trySend(value)
+ }
+ }
+ context.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
+ awaitClose { context.unregisterReceiver(receiver) }
+ }
+
+ suspend fun getBluetoothProfile(
+ profile: Int = BluetoothProfile.HEADSET
+ ): BluetoothProfile2 = withContext(dispatcherProvider.IO) {
+ log(TAG) { "getBluetoothProfile(profile=$profile)" }
+
+ suspendCancellableCoroutine {
+ val connectionState = AtomicBoolean(false)
+ manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
+ override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
+ log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
+ connectionState.set(true)
+ BluetoothProfile2(
+ profileType = profile,
+ profileProxy = proxy,
+ isConnectedAtomic = connectionState
+ ).run { it.resume(this) }
+ }
+
+ override fun onServiceDisconnected(profile: Int) {
+ log(TAG, WARN) { "onServiceDisconnected(profile=$profile" }
+ connectionState.set(false)
+ it.cancel(IOException("BluetoothProfile service disconnected (profile=$profile)"))
+ }
+
+ }, profile)
+ }
+ }
+
+ fun connectedDevices(profile: Int = BluetoothProfile.HEADSET): Flow> = callbackFlow {
+ log(TAG) { "connectedDevices(profile=$profile) starting" }
+ trySend(getBluetoothProfile(profile).connectedDevices)
+
+ val filter = IntentFilter().apply {
+ addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
+ addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
+ }
+
+ val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
+ start()
+ }
+ val handler = Handler(handlerThread.looper)
+
+ val receiver: BroadcastReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" }
+ val action = intent.action
+ if (action == null) {
+ log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
+ return
+ }
+ val device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)?.let {
+ BluetoothDevice2(it)
+ }
+ if (device == null) {
+ log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
+ return
+ }
+
+ this@callbackFlow.launch {
+ val currentDevices = getBluetoothProfile(profile).connectedDevices
+
+ when (action) {
+ BluetoothDevice.ACTION_ACL_CONNECTED -> {
+ log(TAG) { "Adding $device to current devices $currentDevices" }
+ trySend(currentDevices.plus(device))
+ }
+ BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
+ log(TAG) { "Removing $device from current devices $currentDevices" }
+ trySend(currentDevices.minus(device))
+ }
+ }
+ }
+ }
+ }
+ context.registerReceiver(receiver, filter, null, handler)
+
+ awaitClose {
+ log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
+ context.unregisterReceiver(receiver)
+ }
+ }
+
+ companion object {
+ private val TAG = logTag("Bluetooth", "Manager2")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/bluetooth/BluetoothProfile2.kt b/app/src/main/java/eu/darken/cap/common/bluetooth/BluetoothProfile2.kt
new file mode 100644
index 00000000..7e9af879
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/bluetooth/BluetoothProfile2.kt
@@ -0,0 +1,23 @@
+package eu.darken.cap.common.bluetooth
+
+import android.bluetooth.BluetoothProfile
+import java.util.concurrent.atomic.AtomicBoolean
+
+data class BluetoothProfile2(
+ private val profileType: Int,
+ private val profileProxy: BluetoothProfile,
+ private val isConnectedAtomic: AtomicBoolean,
+) {
+
+ val profile: BluetoothProfile
+ get() {
+ if (!isConnected) throw IllegalStateException("Proxy is not connected")
+ return profileProxy
+ }
+
+ val connectedDevices: Set
+ get() = profile.connectedDevices.map { BluetoothDevice2(it) }.toSet()
+
+ val isConnected: Boolean
+ get() = isConnectedAtomic.get()
+}
diff --git a/app/src/main/java/eu/darken/cap/common/collections/MapExtensions.kt b/app/src/main/java/eu/darken/cap/common/collections/MapExtensions.kt
new file mode 100644
index 00000000..0cd0f1c6
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/collections/MapExtensions.kt
@@ -0,0 +1,5 @@
+package eu.darken.cap.common.collections
+
+inline fun Map.mutate(block: MutableMap.() -> Unit): Map {
+ return toMutableMap().apply(block).toMap()
+}
diff --git a/app/src/main/java/eu/darken/cap/common/coroutine/AppCoroutineScope.kt b/app/src/main/java/eu/darken/cap/common/coroutine/AppCoroutineScope.kt
new file mode 100644
index 00000000..19a818a3
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/coroutine/AppCoroutineScope.kt
@@ -0,0 +1,19 @@
+package eu.darken.cap.common.coroutine
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import javax.inject.Inject
+import javax.inject.Qualifier
+import javax.inject.Singleton
+import kotlin.coroutines.CoroutineContext
+
+@Singleton
+class AppCoroutineScope @Inject constructor() : CoroutineScope {
+ override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default
+}
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class AppScope
diff --git a/app/src/main/java/eu/darken/cap/common/coroutine/CoroutineModule.kt b/app/src/main/java/eu/darken/cap/common/coroutine/CoroutineModule.kt
new file mode 100644
index 00000000..e33b54db
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/coroutine/CoroutineModule.kt
@@ -0,0 +1,19 @@
+package eu.darken.cap.common.coroutine
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineScope
+
+@InstallIn(SingletonComponent::class)
+@Module
+abstract class CoroutineModule {
+
+ @Binds
+ abstract fun dispatcherProvider(defaultProvider: DefaultDispatcherProvider): DispatcherProvider
+
+ @Binds
+ @AppScope
+ abstract fun appscope(appCoroutineScope: AppCoroutineScope): CoroutineScope
+}
diff --git a/app/src/main/java/eu/darken/cap/common/coroutine/DefaultDispatcherProvider.kt b/app/src/main/java/eu/darken/cap/common/coroutine/DefaultDispatcherProvider.kt
new file mode 100644
index 00000000..48f0a068
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/coroutine/DefaultDispatcherProvider.kt
@@ -0,0 +1,7 @@
+package eu.darken.cap.common.coroutine
+
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider
diff --git a/app/src/main/java/eu/darken/cap/common/coroutine/DispatcherProvider.kt b/app/src/main/java/eu/darken/cap/common/coroutine/DispatcherProvider.kt
new file mode 100644
index 00000000..6c0c4884
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/coroutine/DispatcherProvider.kt
@@ -0,0 +1,21 @@
+package eu.darken.cap.common.coroutine
+
+import kotlinx.coroutines.Dispatchers
+import kotlin.coroutines.CoroutineContext
+
+// Need this to improve testing
+// Can currently only replace the main-thread dispatcher.
+// https://github.com/Kotlin/kotlinx.coroutines/issues/1365
+@Suppress("PropertyName", "VariableNaming")
+interface DispatcherProvider {
+ val Default: CoroutineContext
+ get() = Dispatchers.Default
+ val Main: CoroutineContext
+ get() = Dispatchers.Main
+ val MainImmediate: CoroutineContext
+ get() = Dispatchers.Main.immediate
+ val Unconfined: CoroutineContext
+ get() = Dispatchers.Unconfined
+ val IO: CoroutineContext
+ get() = Dispatchers.IO
+}
diff --git a/app/src/main/java/eu/darken/cap/common/dagger/AndroidModule.kt b/app/src/main/java/eu/darken/cap/common/dagger/AndroidModule.kt
new file mode 100644
index 00000000..f85cc164
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/dagger/AndroidModule.kt
@@ -0,0 +1,36 @@
+package eu.darken.cap.common.dagger
+
+import android.app.Application
+import android.app.NotificationManager
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import androidx.work.WorkManager
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@InstallIn(SingletonComponent::class)
+@Module
+class AndroidModule {
+
+ @Provides
+ @Singleton
+ fun context(app: Application): Context = app.applicationContext
+
+ @Provides
+ @Singleton
+ fun notificationManager(context: Context): NotificationManager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ @Provides
+ @Singleton
+ fun bluetoothManager(context: Context): BluetoothManager =
+ context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
+
+ @Provides
+ @Singleton
+ fun workerManager(context: Context): WorkManager =
+ WorkManager.getInstance(context)
+}
diff --git a/app/src/main/java/eu/darken/cap/common/debug/bugsnag/BugsnagErrorHandler.kt b/app/src/main/java/eu/darken/cap/common/debug/bugsnag/BugsnagErrorHandler.kt
new file mode 100644
index 00000000..66f6996a
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/debug/bugsnag/BugsnagErrorHandler.kt
@@ -0,0 +1,56 @@
+package eu.darken.cap.common.debug.bugsnag
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageManager
+import com.bugsnag.android.Event
+import com.bugsnag.android.OnErrorCallback
+import dagger.hilt.android.qualifiers.ApplicationContext
+import eu.darken.cap.BuildConfig
+import eu.darken.cap.common.debug.logging.Logging.Priority.WARN
+import eu.darken.cap.common.debug.logging.asLog
+import eu.darken.cap.common.debug.logging.log
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class BugsnagErrorHandler @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val bugsnagLogger: BugsnagLogger,
+) : OnErrorCallback {
+
+ override fun onError(event: Event): Boolean {
+ bugsnagLogger.injectLog(event)
+
+ TAB_APP.also { tab ->
+ event.addMetadata(tab, "gitSha", BuildConfig.GITSHA)
+ event.addMetadata(tab, "buildTime", BuildConfig.BUILDTIME)
+
+ context.tryFormattedSignature()?.let { event.addMetadata(tab, "signatures", it) }
+ }
+
+ return !BuildConfig.DEBUG
+ }
+
+ companion object {
+ private const val TAB_APP = "app"
+
+ @Suppress("DEPRECATION")
+ @SuppressLint("PackageManagerGetSignatures")
+ fun Context.tryFormattedSignature(): String? = try {
+ packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES).signatures?.let { sigs ->
+ val sb = StringBuilder("[")
+ for (i in sigs.indices) {
+ sb.append(sigs[i].hashCode())
+ if (i + 1 != sigs.size) sb.append(", ")
+ }
+ sb.append("]")
+ sb.toString()
+ }
+ } catch (e: Exception) {
+ log(WARN) { e.asLog() }
+ null
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/debug/bugsnag/BugsnagLogger.kt b/app/src/main/java/eu/darken/cap/common/debug/bugsnag/BugsnagLogger.kt
new file mode 100644
index 00000000..4a36e4d1
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/debug/bugsnag/BugsnagLogger.kt
@@ -0,0 +1,47 @@
+package eu.darken.cap.common.debug.bugsnag
+
+import com.bugsnag.android.Event
+import eu.darken.cap.common.debug.logging.Logging
+import eu.darken.cap.common.debug.logging.asLog
+import java.lang.String.format
+import java.util.*
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class BugsnagLogger @Inject constructor() : Logging.Logger {
+
+ // Adding one to the initial size accounts for the add before remove.
+ private val buffer: Deque = ArrayDeque(BUFFER_SIZE + 1)
+
+ override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map?) {
+ val line = "${System.currentTimeMillis()} ${priority.toLabel()}/$tag: $message"
+ synchronized(buffer) {
+ buffer.addLast(line)
+ if (buffer.size > BUFFER_SIZE) {
+ buffer.removeFirst()
+ }
+ }
+ }
+
+ fun injectLog(event: Event) {
+ synchronized(buffer) {
+ var i = 100
+ buffer.forEach { event.addMetadata("Log", format(Locale.ROOT, "%03d", i++), it) }
+ event.addMetadata("Log", format(Locale.ROOT, "%03d", i), event.originalError?.asLog())
+ }
+ }
+
+ companion object {
+ private const val BUFFER_SIZE = 200
+
+ private fun Logging.Priority.toLabel(): String = when (this) {
+ Logging.Priority.VERBOSE -> "V"
+ Logging.Priority.DEBUG -> "D"
+ Logging.Priority.INFO -> "I"
+ Logging.Priority.WARN -> "W"
+ Logging.Priority.ERROR -> "E"
+ Logging.Priority.ASSERT -> "WTF"
+ }
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/common/debug/bugsnag/NOPBugsnagErrorHandler.kt b/app/src/main/java/eu/darken/cap/common/debug/bugsnag/NOPBugsnagErrorHandler.kt
new file mode 100644
index 00000000..5366b43d
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/debug/bugsnag/NOPBugsnagErrorHandler.kt
@@ -0,0 +1,19 @@
+package eu.darken.cap.common.debug.bugsnag
+
+import com.bugsnag.android.Event
+import com.bugsnag.android.OnErrorCallback
+import eu.darken.cap.common.debug.logging.Logging.Priority.WARN
+import eu.darken.cap.common.debug.logging.asLog
+import eu.darken.cap.common.debug.logging.log
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NOPBugsnagErrorHandler @Inject constructor() : OnErrorCallback {
+
+ override fun onError(event: Event): Boolean {
+ log(WARN) { "Error, but skipping bugsnag due to user opt-out: ${event.originalError?.asLog()}" }
+ return false
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/debug/logging/LogCatLogger.kt b/app/src/main/java/eu/darken/cap/common/debug/logging/LogCatLogger.kt
new file mode 100644
index 00000000..696a1f27
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/debug/logging/LogCatLogger.kt
@@ -0,0 +1,49 @@
+package eu.darken.cap.common.debug.logging
+
+import android.os.Build
+import android.util.Log
+import kotlin.math.min
+
+class LogCatLogger : Logging.Logger {
+
+ override fun isLoggable(priority: Logging.Priority): Boolean = true
+
+ override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map?) {
+
+ val trimmedTag = if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) {
+ tag
+ } else {
+ tag.substring(0, MAX_TAG_LENGTH)
+ }
+
+ if (message.length < MAX_LOG_LENGTH) {
+ writeToLogcat(priority.intValue, trimmedTag, message)
+ return
+ }
+
+ // Split by line, then ensure each line can fit into Log's maximum length.
+ var i = 0
+ val length = message.length
+ while (i < length) {
+ var newline = message.indexOf('\n', i)
+ newline = if (newline != -1) newline else length
+ do {
+ val end = min(newline, i + MAX_LOG_LENGTH)
+ val part = message.substring(i, end)
+ writeToLogcat(priority.intValue, trimmedTag, part)
+ i = end
+ } while (i < newline)
+ i++
+ }
+ }
+
+ private fun writeToLogcat(priority: Int, tag: String, part: String) = when (priority) {
+ Log.ASSERT -> Log.wtf(tag, part)
+ else -> Log.println(priority, tag, part)
+ }
+
+ companion object {
+ private const val MAX_LOG_LENGTH = 4000
+ private const val MAX_TAG_LENGTH = 23
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/debug/logging/LogExtensions.kt b/app/src/main/java/eu/darken/cap/common/debug/logging/LogExtensions.kt
new file mode 100644
index 00000000..cf594547
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/debug/logging/LogExtensions.kt
@@ -0,0 +1,10 @@
+package eu.darken.cap.common.debug.logging
+
+fun logTag(vararg tags: String): String {
+ val sb = StringBuilder("CAP:")
+ for (i in tags.indices) {
+ sb.append(tags[i])
+ if (i < tags.size - 1) sb.append(":")
+ }
+ return sb.toString()
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/debug/logging/Logging.kt b/app/src/main/java/eu/darken/cap/common/debug/logging/Logging.kt
new file mode 100644
index 00000000..e861ea42
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/debug/logging/Logging.kt
@@ -0,0 +1,132 @@
+package eu.darken.cap.common.debug.logging
+
+import java.io.PrintWriter
+import java.io.StringWriter
+
+/**
+ * Inspired by
+ * https://github.com/PaulWoitaschek/Slimber
+ * https://github.com/square/logcat
+ * https://github.com/JakeWharton/timber
+ */
+
+object Logging {
+ enum class Priority(
+ val intValue: Int,
+ val shortLabel: String
+ ) {
+ VERBOSE(2, "V"),
+ DEBUG(3, "D"),
+ INFO(4, "I"),
+ WARN(5, "W"),
+ ERROR(6, "E"),
+ ASSERT(7, "WTF");
+ }
+
+ interface Logger {
+ fun isLoggable(priority: Priority): Boolean = true
+
+ fun log(
+ priority: Priority,
+ tag: String,
+ message: String,
+ metaData: Map?
+ )
+ }
+
+ private val internalLoggers = mutableListOf()
+
+ val loggers: List
+ get() = synchronized(internalLoggers) { internalLoggers.toList() }
+
+ val hasReceivers: Boolean
+ get() = synchronized(internalLoggers) {
+ internalLoggers.isNotEmpty()
+ }
+
+ fun install(logger: Logger) {
+ synchronized(internalLoggers) { internalLoggers.add(logger) }
+ log { "Was installed $logger" }
+ }
+
+ fun remove(logger: Logger) {
+ log { "Removing: $logger" }
+ synchronized(internalLoggers) { internalLoggers.remove(logger) }
+ }
+
+ fun logInternal(
+ tag: String,
+ priority: Priority,
+ metaData: Map?,
+ message: String
+ ) {
+ val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
+ snapshot
+ .filter { it.isLoggable(priority) }
+ .forEach {
+ it.log(
+ priority = priority,
+ tag = tag,
+ metaData = metaData,
+ message = message
+ )
+ }
+ }
+
+ fun clearAll() {
+ log { "Clearing all loggers" }
+ synchronized(internalLoggers) { internalLoggers.clear() }
+ }
+}
+
+inline fun Any.log(
+ priority: Logging.Priority = Logging.Priority.DEBUG,
+ metaData: Map? = null,
+ message: () -> String,
+) {
+ if (Logging.hasReceivers) {
+ Logging.logInternal(
+ tag = "CAP:${logTagViaCallSite()}",
+ priority = priority,
+ metaData = metaData,
+ message = message(),
+ )
+ }
+}
+
+inline fun log(
+ tag: String,
+ priority: Logging.Priority = Logging.Priority.DEBUG,
+ metaData: Map? = null,
+ message: () -> String,
+) {
+ if (Logging.hasReceivers) {
+ Logging.logInternal(
+ tag = tag,
+ priority = priority,
+ metaData = metaData,
+ message = message(),
+ )
+ }
+}
+
+fun Throwable.asLog(): String {
+ val stringWriter = StringWriter(256)
+ val printWriter = PrintWriter(stringWriter, false)
+ printStackTrace(printWriter)
+ printWriter.flush()
+ return stringWriter.toString()
+}
+
+@PublishedApi
+internal fun Any.logTagViaCallSite(): String {
+ val javaClass = this::class.java
+ val fullClassName = javaClass.name
+ val outerClassName = fullClassName.substringBefore('$')
+ val simplerOuterClassName = outerClassName.substringAfterLast('.')
+ return if (simplerOuterClassName.isEmpty()) {
+ fullClassName
+ } else {
+ simplerOuterClassName.removeSuffix("Kt")
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/common/error/ErrorDialog.kt b/app/src/main/java/eu/darken/cap/common/error/ErrorDialog.kt
new file mode 100644
index 00000000..0301aae3
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/error/ErrorDialog.kt
@@ -0,0 +1,18 @@
+package eu.darken.cap.common.error
+
+import android.content.Context
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+
+fun Throwable.asErrorDialogBuilder(
+ context: Context
+) = MaterialAlertDialogBuilder(context).apply {
+ val error = this@asErrorDialogBuilder
+ val localizedError = error.localized(context)
+
+ setTitle(localizedError.label)
+ setMessage(localizedError.description)
+
+ setPositiveButton(android.R.string.ok) { _, _ ->
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/error/ErrorEventSource.kt b/app/src/main/java/eu/darken/cap/common/error/ErrorEventSource.kt
new file mode 100644
index 00000000..3dedc6ae
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/error/ErrorEventSource.kt
@@ -0,0 +1,7 @@
+package eu.darken.cap.common.error
+
+import eu.darken.cap.common.livedata.SingleLiveEvent
+
+interface ErrorEventSource {
+ val errorEvents: SingleLiveEvent
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/error/LocalizedError.kt b/app/src/main/java/eu/darken/cap/common/error/LocalizedError.kt
new file mode 100644
index 00000000..2b66ef82
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/error/LocalizedError.kt
@@ -0,0 +1,36 @@
+package eu.darken.cap.common.error
+
+import android.content.Context
+import eu.darken.cap.R
+
+interface HasLocalizedError {
+ fun getLocalizedError(context: Context): LocalizedError
+}
+
+data class LocalizedError(
+ val throwable: Throwable,
+ val label: String,
+ val description: String
+) {
+ fun asText() = "$label:\n$description"
+}
+
+fun Throwable.localized(c: Context): LocalizedError = when {
+ this is HasLocalizedError -> this.getLocalizedError(c)
+ localizedMessage != null -> LocalizedError(
+ throwable = this,
+ label = "${c.getString(R.string.general_error_label)}: ${this::class.simpleName!!}",
+ description = localizedMessage ?: getStackTracePeek()
+ )
+ else -> LocalizedError(
+ throwable = this,
+ label = "${c.getString(R.string.general_error_label)}: ${this::class.simpleName!!}",
+ description = getStackTracePeek()
+ )
+}
+
+private fun Throwable.getStackTracePeek() = this.stackTraceToString()
+ .lines()
+ .filterIndexed { index, _ -> index > 1 }
+ .take(3)
+ .joinToString("\n")
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/error/ThrowableExtensions.kt b/app/src/main/java/eu/darken/cap/common/error/ThrowableExtensions.kt
new file mode 100644
index 00000000..21f9662a
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/error/ThrowableExtensions.kt
@@ -0,0 +1,42 @@
+package eu.darken.cap.common.error
+
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.lang.reflect.InvocationTargetException
+import kotlin.reflect.KClass
+
+val Throwable.causes: Sequence
+ get() = sequence {
+ var subCause = cause
+ while (subCause != null) {
+ yield(subCause)
+ subCause = subCause.cause
+ }
+ }
+
+fun Throwable.getRootCause(): Throwable {
+ var error = this
+ while (error.cause != null) {
+ error = error.cause!!
+ }
+ if (error is InvocationTargetException) {
+ error = error.targetException
+ }
+ return error
+}
+
+fun Throwable.hasCause(exceptionClazz: KClass): Boolean {
+ if (exceptionClazz.isInstance(this)) return true
+ return exceptionClazz.isInstance(this.getRootCause())
+}
+
+fun Throwable.getStackTraceString(): String {
+ val sw = StringWriter(256)
+ val pw = PrintWriter(sw, false)
+ printStackTrace(pw)
+ pw.flush()
+ return sw.toString()
+}
+
+fun Throwable.tryUnwrap(kClass: KClass = RuntimeException::class): Throwable =
+ if (!kClass.isInstance(this)) this else cause ?: this
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/flow/DynamicStateFlow.kt b/app/src/main/java/eu/darken/cap/common/flow/DynamicStateFlow.kt
new file mode 100644
index 00000000..80e98d5b
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/flow/DynamicStateFlow.kt
@@ -0,0 +1,149 @@
+package eu.darken.cap.common.flow
+
+import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
+import eu.darken.cap.common.debug.logging.asLog
+import eu.darken.cap.common.debug.logging.log
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.plus
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * A thread safe stateful flow that can be updated blocking and async with a lazy initial value provider.
+ *
+ * @param loggingTag will be prepended to logging tag, i.e. "$loggingTag:HD"
+ * @param parentScope on which the update operations and callbacks will be executed on
+ * @param coroutineContext used in combination with [CoroutineScope]
+ * @param startValueProvider provides the first value, errors will be rethrown on [CoroutineScope]
+ */
+class DynamicStateFlow(
+ loggingTag: String? = null,
+ parentScope: CoroutineScope,
+ coroutineContext: CoroutineContext = parentScope.coroutineContext,
+ private val startValueProvider: suspend CoroutineScope.() -> T
+) {
+ private val lTag = loggingTag?.let { "$it:DSFlow" }
+
+ private val updateActions = MutableSharedFlow>(
+ replay = Int.MAX_VALUE,
+ extraBufferCapacity = Int.MAX_VALUE,
+ onBufferOverflow = BufferOverflow.SUSPEND
+ )
+ private val valueGuard = Mutex()
+
+ private val producer: Flow> = channelFlow {
+ var currentValue = valueGuard.withLock {
+ lTag?.let { log(it, VERBOSE) { "Providing startValue..." } }
+
+ startValueProvider().also { startValue ->
+ val initializer = Update(onError = null, onModify = { startValue })
+ send(State(value = startValue, updatedBy = initializer))
+ lTag?.let { log(it, VERBOSE) { "...startValue provided and emitted." } }
+ }
+ }
+
+ updateActions.collect { update ->
+ currentValue = valueGuard.withLock {
+ try {
+ update.onModify(currentValue).also {
+ send(State(value = it, updatedBy = update))
+ }
+ } catch (e: Exception) {
+ lTag?.let {
+ log(it, VERBOSE) { "Data modifying failed (onError=${update.onError}): ${e.asLog()}" }
+ }
+
+ if (update.onError != null) {
+ update.onError.invoke(e)
+ } else {
+ send(State(value = currentValue, error = e, updatedBy = update))
+ }
+
+ currentValue
+ }
+ }
+ }
+
+ lTag?.let { log(it, VERBOSE) { "internal channelFlow finished." } }
+ }
+
+ private val internalFlow = producer
+ .onStart { lTag?.let { log(it, VERBOSE) { "Internal onStart" } } }
+ .onCompletion { err ->
+ when {
+ err is CancellationException -> {
+ lTag?.let { log(it, VERBOSE) { "internal onCompletion() due to cancellation" } }
+ }
+ err != null -> {
+ lTag?.let { log(it, VERBOSE) { "internal onCompletion() due to error: ${err.asLog()}" } }
+ }
+ else -> {
+ lTag?.let { log(it, VERBOSE) { "internal onCompletion()" } }
+ }
+ }
+ }
+ .shareIn(
+ scope = parentScope + coroutineContext,
+ replay = 1,
+ started = SharingStarted.Lazily
+ )
+
+ val flow: Flow = internalFlow
+ .map { it.value }
+ .distinctUntilChanged()
+
+ suspend fun value() = flow.first()
+
+ /**
+ * Non blocking update method.
+ * Gets executed on the scope and context this instance was initialized with.
+ *
+ * @param onError if you don't provide this, and exception in [onUpdate] will the scope passed to this class
+ */
+ fun updateAsync(
+ onError: (suspend (Exception) -> Unit) = { throw it },
+ onUpdate: suspend T.() -> T,
+ ) {
+ val update: Update = Update(
+ onModify = onUpdate,
+ onError = onError
+ )
+ runBlocking { updateActions.emit(update) }
+ }
+
+ /**
+ * Blocking update method
+ * Gets executed on the scope and context this instance was initialized with.
+ * Waiting will happen on the callers scope.
+ *
+ * Any errors that occurred during [action] will be rethrown by this method.
+ */
+ suspend fun updateBlocking(action: suspend T.() -> T): T {
+ val update: Update = Update(onModify = action)
+ updateActions.emit(update)
+
+ lTag?.let { log(it, VERBOSE) { "Waiting for update." } }
+ val ourUpdate = internalFlow.first { it.updatedBy == update }
+ lTag?.let { log(it, VERBOSE) { "Finished waiting, got $ourUpdate" } }
+
+ ourUpdate.error?.let { throw it }
+
+ return ourUpdate.value
+ }
+
+ private data class Update(
+ val onModify: suspend T.() -> T,
+ val onError: (suspend (Exception) -> Unit)? = null,
+ )
+
+ private data class State(
+ val value: T,
+ val error: Exception? = null,
+ val updatedBy: Update,
+ )
+}
diff --git a/app/src/main/java/eu/darken/cap/common/flow/DynamicStateFlowExtensions.kt b/app/src/main/java/eu/darken/cap/common/flow/DynamicStateFlowExtensions.kt
new file mode 100644
index 00000000..709f859e
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/flow/DynamicStateFlowExtensions.kt
@@ -0,0 +1,3 @@
+package eu.darken.cap.common.flow
+
+
diff --git a/app/src/main/java/eu/darken/cap/common/flow/FlowCombineExtensions.kt b/app/src/main/java/eu/darken/cap/common/flow/FlowCombineExtensions.kt
new file mode 100644
index 00000000..bab5566e
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/flow/FlowCombineExtensions.kt
@@ -0,0 +1,213 @@
+package eu.darken.cap.common.flow
+
+import kotlinx.coroutines.flow.Flow
+
+
+//@Suppress("UNCHECKED_CAST", "LongParameterList")
+//inline fun combine(
+// flow: Flow,
+// flow2: Flow,
+// crossinline transform: suspend (T1, T2) -> R
+//): Flow = kotlinx.coroutines.flow.combine(
+// flow,
+// flow2
+//) { args: Array<*> ->
+// transform(
+// args[0] as T1,
+// args[1] as T2
+// )
+//}
+
+@Suppress("UNCHECKED_CAST", "LongParameterList")
+inline fun combine(
+ flow: Flow,
+ flow2: Flow,
+ flow3: Flow,
+ crossinline transform: suspend (T1, T2, T3) -> R
+): Flow = kotlinx.coroutines.flow.combine(
+ flow,
+ flow2,
+ flow3,
+) { args: Array<*> ->
+ transform(
+ args[0] as T1,
+ args[1] as T2,
+ args[2] as T3,
+ )
+}
+
+@Suppress("UNCHECKED_CAST", "LongParameterList")
+inline fun combine(
+ flow: Flow,
+ flow2: Flow,
+ flow3: Flow,
+ flow4: Flow,
+ flow5: Flow,
+ crossinline transform: suspend (T1, T2, T3, T4, T5) -> R
+): Flow = kotlinx.coroutines.flow.combine(
+ flow,
+ flow2,
+ flow3,
+ flow4,
+ flow5
+) { args: Array<*> ->
+ transform(
+ args[0] as T1,
+ args[1] as T2,
+ args[2] as T3,
+ args[3] as T4,
+ args[4] as T5
+ )
+}
+
+@Suppress("UNCHECKED_CAST", "LongParameterList")
+inline fun combine(
+ flow: Flow,
+ flow2: Flow,
+ flow3: Flow,
+ flow4: Flow,
+ flow5: Flow,
+ flow6: Flow,
+ crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R
+): Flow = kotlinx.coroutines.flow.combine(
+ flow,
+ flow2,
+ flow3,
+ flow4,
+ flow5,
+ flow6
+) { args: Array<*> ->
+ transform(
+ args[0] as T1,
+ args[1] as T2,
+ args[2] as T3,
+ args[3] as T4,
+ args[4] as T5,
+ args[5] as T6
+ )
+}
+
+@Suppress("UNCHECKED_CAST", "LongParameterList")
+inline fun combine(
+ flow: Flow,
+ flow2: Flow,
+ flow3: Flow,
+ flow4: Flow,
+ flow5: Flow,
+ flow6: Flow,
+ flow7: Flow,
+ crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R
+): Flow = kotlinx.coroutines.flow.combine(
+ flow,
+ flow2,
+ flow3,
+ flow4,
+ flow5,
+ flow6,
+ flow7
+) { args: Array<*> ->
+ transform(
+ args[0] as T1,
+ args[1] as T2,
+ args[2] as T3,
+ args[3] as T4,
+ args[4] as T5,
+ args[5] as T6,
+ args[6] as T7
+ )
+}
+
+@Suppress("UNCHECKED_CAST", "LongParameterList")
+inline fun combine(
+ flow: Flow,
+ flow2: Flow,
+ flow3: Flow,
+ flow4: Flow,
+ flow5: Flow,
+ flow6: Flow,
+ flow7: Flow,
+ flow8: Flow,
+ crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R
+): Flow = kotlinx.coroutines.flow.combine(
+ flow,
+ flow2,
+ flow3,
+ flow4,
+ flow5,
+ flow6,
+ flow7,
+ flow8
+) { args: Array<*> ->
+ transform(
+ args[0] as T1,
+ args[1] as T2,
+ args[2] as T3,
+ args[3] as T4,
+ args[4] as T5,
+ args[5] as T6,
+ args[6] as T7,
+ args[7] as T8
+ )
+}
+
+@Suppress("UNCHECKED_CAST", "LongParameterList")
+inline fun combine(
+ flow: Flow,
+ flow2: Flow,
+ flow3: Flow,
+ flow4: Flow,
+ flow5: Flow,
+ flow6: Flow,
+ flow7: Flow,
+ flow8: Flow,
+ flow9: Flow,
+ flow10: Flow,
+ crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10) -> R
+): Flow = kotlinx.coroutines.flow.combine(
+ flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9, flow10
+) { args: Array<*> ->
+ transform(
+ args[0] as T1,
+ args[1] as T2,
+ args[2] as T3,
+ args[3] as T4,
+ args[4] as T5,
+ args[5] as T6,
+ args[6] as T7,
+ args[7] as T8,
+ args[8] as T9,
+ args[9] as T10
+ )
+}
+
+@Suppress("UNCHECKED_CAST", "LongParameterList")
+inline fun combine(
+ flow: Flow,
+ flow2: Flow,
+ flow3: Flow,
+ flow4: Flow,
+ flow5: Flow,
+ flow6: Flow,
+ flow7: Flow,
+ flow8: Flow,
+ flow9: Flow,
+ flow10: Flow,
+ flow11: Flow,
+ crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11) -> R
+): Flow = kotlinx.coroutines.flow.combine(
+ flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9, flow10, flow11
+) { args: Array<*> ->
+ transform(
+ args[0] as T1,
+ args[1] as T2,
+ args[2] as T3,
+ args[3] as T4,
+ args[4] as T5,
+ args[5] as T6,
+ args[6] as T7,
+ args[7] as T8,
+ args[8] as T9,
+ args[9] as T10,
+ args[10] as T11
+ )
+}
diff --git a/app/src/main/java/eu/darken/cap/common/flow/FlowExtensions.kt b/app/src/main/java/eu/darken/cap/common/flow/FlowExtensions.kt
new file mode 100644
index 00000000..4e450890
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/flow/FlowExtensions.kt
@@ -0,0 +1,74 @@
+package eu.darken.cap.common.flow
+
+import eu.darken.cap.common.debug.logging.Logging.Priority.ERROR
+import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
+import eu.darken.cap.common.debug.logging.asLog
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.error.hasCause
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.*
+import kotlin.time.Duration
+
+/**
+ * Create a stateful flow, with the initial value of null, but never emits a null value.
+ * Helper method to create a new flow without suspending and without initial value
+ * The flow collector will just wait for the first value
+ */
+fun Flow.shareLatest(
+ tag: String? = null,
+ scope: CoroutineScope,
+ started: SharingStarted = SharingStarted.WhileSubscribed(replayExpirationMillis = 0)
+) = this
+ .onStart { if (tag != null) log(tag) { "shareLatest(...) start" } }
+ .onEach { if (tag != null) log(tag) { "shareLatest(...) emission: $it" } }
+ .onCompletion { if (tag != null) log(tag) { "shareLatest(...) completed." } }
+ .catch {
+ if (tag != null) log(tag) { "shareLatest(...) catch(): ${it.asLog()}" }
+ throw it
+ }
+ .stateIn(
+ scope = scope,
+ started = started,
+ initialValue = null
+ )
+ .filterNotNull()
+
+fun Flow.replayingShare(scope: CoroutineScope) = this.shareIn(
+ scope = scope,
+ replay = 1,
+ started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO)
+)
+
+internal fun Flow.withPrevious(): Flow> = this
+ .scan(Pair(null, null)) { previous, current -> Pair(previous.second, current) }
+ .drop(1)
+ .map {
+ @Suppress("UNCHECKED_CAST")
+ it as Pair
+ }
+
+
+fun Flow.onError(block: suspend (Throwable) -> Unit) = this.catch {
+ block(it)
+ throw it
+}
+
+fun Flow.takeUntilAfter(predicate: suspend (T) -> Boolean) = transformWhile {
+ val fullfilled = predicate(it)
+ emit(it)
+ !fullfilled // We keep emitting until condition is fullfilled = true
+}
+
+fun Flow.setupCommonEventHandlers(tag: String, identifier: () -> String) = this
+ .onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } }
+ .onEach { log(tag, VERBOSE) { "${identifier()}.onEach(): $it" } }
+ .onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } }
+ .catch {
+ if (it.hasCause(CancellationException::class)) {
+ log(tag, VERBOSE) { "${identifier()} cancelled" }
+ } else {
+ log(tag, ERROR) { "${identifier()} failed: ${it.asLog()}" }
+ throw it
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/lists/BaseAdapter.kt b/app/src/main/java/eu/darken/cap/common/lists/BaseAdapter.kt
new file mode 100644
index 00000000..fc50dd0e
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/BaseAdapter.kt
@@ -0,0 +1,58 @@
+package eu.darken.cap.common.lists
+
+import android.content.Context
+import android.content.res.Resources
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.annotation.*
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import eu.darken.cap.common.getColorForAttr
+
+abstract class BaseAdapter : RecyclerView.Adapter() {
+
+ @CallSuper
+ final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
+ return onCreateBaseVH(parent, viewType)
+ }
+
+ abstract fun onCreateBaseVH(parent: ViewGroup, viewType: Int): T
+
+ @CallSuper
+ final override fun onBindViewHolder(holder: T, position: Int) {
+ onBindBaseVH(holder, position, mutableListOf())
+ }
+
+ @CallSuper
+ final override fun onBindViewHolder(holder: T, position: Int, payloads: MutableList) {
+ onBindBaseVH(holder, position, payloads)
+ }
+
+ abstract fun onBindBaseVH(holder: T, position: Int, payloads: MutableList = mutableListOf())
+
+ abstract class VH(@LayoutRes layoutRes: Int, private val parent: ViewGroup) : RecyclerView.ViewHolder(
+ LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
+ ) {
+
+ val context: Context
+ get() = parent.context
+
+ val resources: Resources
+ get() = context.resources
+
+ val layoutInflater: LayoutInflater
+ get() = LayoutInflater.from(context)
+
+ fun getColor(@ColorRes colorRes: Int): Int = ContextCompat.getColor(context, colorRes)
+
+ fun getColorForAttr(@AttrRes attrRes: Int): Int = context.getColorForAttr(attrRes)
+
+ fun getString(@StringRes stringRes: Int, vararg args: Any): String = context.getString(stringRes, *args)
+
+ fun getQuantityString(@PluralsRes pluralRes: Int, quantity: Int, vararg args: Any): String =
+ context.resources.getQuantityString(pluralRes, quantity, *args)
+
+ fun getQuantityString(@PluralsRes pluralRes: Int, quantity: Int): String =
+ context.resources.getQuantityString(pluralRes, quantity, quantity)
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/common/lists/BindableVH.kt b/app/src/main/java/eu/darken/cap/common/lists/BindableVH.kt
new file mode 100644
index 00000000..b5c64f20
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/BindableVH.kt
@@ -0,0 +1,14 @@
+package eu.darken.cap.common.lists
+
+import androidx.viewbinding.ViewBinding
+
+interface BindableVH {
+
+ val viewBinding: Lazy
+
+ val onBindData: ViewBindingT.(item: ItemT, payloads: List) -> Unit
+
+ fun bind(item: ItemT, payloads: MutableList = mutableListOf()) = with(viewBinding.value) {
+ onBindData(item, payloads)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/lists/DataAdapter.kt b/app/src/main/java/eu/darken/cap/common/lists/DataAdapter.kt
new file mode 100644
index 00000000..57f66a5b
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/DataAdapter.kt
@@ -0,0 +1,13 @@
+package eu.darken.cap.common.lists
+
+import androidx.recyclerview.widget.RecyclerView
+
+interface DataAdapter {
+ val data: MutableList
+}
+
+fun X.update(newData: List?, notify: Boolean = true) where X : DataAdapter, X : RecyclerView.Adapter<*> {
+ data.clear()
+ if (newData != null) data.addAll(newData)
+ if (notify) notifyDataSetChanged()
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/lists/ListItem.kt b/app/src/main/java/eu/darken/cap/common/lists/ListItem.kt
new file mode 100644
index 00000000..efcd56d0
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/ListItem.kt
@@ -0,0 +1,3 @@
+package eu.darken.cap.common.lists
+
+interface ListItem
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/lists/RecyclerViewExtensions.kt b/app/src/main/java/eu/darken/cap/common/lists/RecyclerViewExtensions.kt
new file mode 100644
index 00000000..16bd5cd3
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/RecyclerViewExtensions.kt
@@ -0,0 +1,13 @@
+package eu.darken.cap.common.lists
+
+import androidx.recyclerview.widget.DefaultItemAnimator
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+
+fun RecyclerView.setupDefaults(adapter: RecyclerView.Adapter<*>? = null, dividers: Boolean = true) = apply {
+ layoutManager = LinearLayoutManager(context)
+ itemAnimator = DefaultItemAnimator()
+ if (dividers) addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
+ if (adapter != null) this.adapter = adapter
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/lists/differ/AsyncDiffer.kt b/app/src/main/java/eu/darken/cap/common/lists/differ/AsyncDiffer.kt
new file mode 100644
index 00000000..79d660dd
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/differ/AsyncDiffer.kt
@@ -0,0 +1,43 @@
+package eu.darken.cap.common.lists.differ
+
+import androidx.recyclerview.widget.AsyncListDiffer
+import androidx.recyclerview.widget.DiffUtil
+import eu.darken.cap.common.lists.modular.ModularAdapter
+import eu.darken.cap.common.lists.modular.mods.StableIdMod
+
+class AsyncDiffer internal constructor(
+ adapter: A,
+ compareItem: (T, T) -> Boolean = { i1, i2 -> i1.stableId == i2.stableId },
+ compareItemContent: (T, T) -> Boolean = { i1, i2 -> i1 == i2 },
+ determinePayload: (T, T) -> Any? = { i1, i2 ->
+ when {
+ i1::class == i2::class -> i1.payloadProvider?.invoke(i1, i2)
+ else -> null
+ }
+ }
+) where A : HasAsyncDiffer, A : ModularAdapter<*> {
+ private val callback = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = compareItem(oldItem, newItem)
+ override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = compareItemContent(oldItem, newItem)
+ override fun getChangePayload(oldItem: T, newItem: T): Any? = determinePayload(oldItem, newItem)
+ }
+
+ private val internalList = mutableListOf()
+ private val listDiffer = AsyncListDiffer(adapter, callback)
+
+ val currentList: List
+ get() = synchronized(internalList) { internalList }
+
+ init {
+ adapter.modules.add(0, StableIdMod(currentList))
+ }
+
+ fun submitUpdate(newData: List) {
+ listDiffer.submitList(newData) {
+ synchronized(internalList) {
+ internalList.clear()
+ internalList.addAll(newData)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/lists/differ/AsyncDifferExtensions.kt b/app/src/main/java/eu/darken/cap/common/lists/differ/AsyncDifferExtensions.kt
new file mode 100644
index 00000000..ee6f714c
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/differ/AsyncDifferExtensions.kt
@@ -0,0 +1,15 @@
+package eu.darken.cap.common.lists.differ
+
+import androidx.recyclerview.widget.RecyclerView
+import eu.darken.cap.common.lists.modular.ModularAdapter
+
+
+fun X.update(newData: List?)
+ where X : HasAsyncDiffer, X : RecyclerView.Adapter<*> {
+
+ asyncDiffer.submitUpdate(newData ?: emptyList())
+}
+
+fun A.setupDiffer(): AsyncDiffer
+ where A : HasAsyncDiffer, A : ModularAdapter<*> =
+ AsyncDiffer(this)
diff --git a/app/src/main/java/eu/darken/cap/common/lists/differ/DifferItem.kt b/app/src/main/java/eu/darken/cap/common/lists/differ/DifferItem.kt
new file mode 100644
index 00000000..db01100a
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/differ/DifferItem.kt
@@ -0,0 +1,10 @@
+package eu.darken.cap.common.lists.differ
+
+import eu.darken.cap.common.lists.ListItem
+
+interface DifferItem : ListItem {
+ val stableId: Long
+
+ val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)?
+ get() = null
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/lists/differ/HasAsyncDiffer.kt b/app/src/main/java/eu/darken/cap/common/lists/differ/HasAsyncDiffer.kt
new file mode 100644
index 00000000..24d56919
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/differ/HasAsyncDiffer.kt
@@ -0,0 +1,10 @@
+package eu.darken.cap.common.lists.differ
+
+interface HasAsyncDiffer {
+
+ val data: List
+ get() = asyncDiffer.currentList
+
+ val asyncDiffer: AsyncDiffer<*, T>
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/lists/modular/ModularAdapter.kt b/app/src/main/java/eu/darken/cap/common/lists/modular/ModularAdapter.kt
new file mode 100644
index 00000000..83d8769c
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/modular/ModularAdapter.kt
@@ -0,0 +1,95 @@
+package eu.darken.cap.common.lists.modular
+
+import android.view.ViewGroup
+import androidx.annotation.CallSuper
+import androidx.annotation.LayoutRes
+import androidx.recyclerview.widget.RecyclerView
+import eu.darken.cap.common.lists.BaseAdapter
+
+abstract class ModularAdapter : BaseAdapter() {
+ val modules = mutableListOf()
+
+ init {
+ modules.filterIsInstance().forEach { it.onAdapterReady(this) }
+ }
+
+ override fun getItemId(position: Int): Long {
+ modules.filterIsInstance().forEach {
+ val id = it.getItemId(this, position)
+ if (id != null) return id
+ }
+ return super.getItemId(position)
+ }
+
+ @CallSuper
+ override fun getItemViewType(position: Int): Int {
+ modules.filterIsInstance().forEach {
+ val type = it.onGetItemType(this, position)
+ if (type != null) return type
+ }
+ return super.getItemViewType(position)
+ }
+
+ override fun onCreateBaseVH(parent: ViewGroup, viewType: Int): VH {
+ modules.filterIsInstance>().forEach {
+ val vh = it.onCreateModularVH(this, parent, viewType)
+ if (vh != null) return vh
+ }
+ throw IllegalStateException("Couldn't create VH for type $viewType with $parent")
+ }
+
+ @CallSuper
+ override fun onBindBaseVH(holder: VH, position: Int, payloads: MutableList) {
+ modules.filterIsInstance>().forEach {
+ it.onBindModularVH(this, holder, position, payloads)
+ it.onPostBind(this, holder, position)
+ }
+ }
+
+ @CallSuper
+ override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
+ modules.filterIsInstance().forEach { it.onAttachedToRecyclerView(recyclerView) }
+ super.onAttachedToRecyclerView(recyclerView)
+ }
+
+ @CallSuper
+ override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
+ modules.filterIsInstance().forEach { it.onDetachedFromRecyclerView(recyclerView) }
+ super.onDetachedFromRecyclerView(recyclerView)
+ }
+
+ abstract class VH(@LayoutRes layoutRes: Int, parent: ViewGroup) : BaseAdapter.VH(layoutRes, parent)
+
+ interface Module {
+ interface Setup {
+ fun onAdapterReady(adapter: ModularAdapter<*>)
+ }
+
+ interface Creator : Module {
+ fun onCreateModularVH(adapter: ModularAdapter, parent: ViewGroup, viewType: Int): T?
+ }
+
+ interface Binder : Module {
+ fun onBindModularVH(adapter: ModularAdapter, vh: T, pos: Int, payloads: MutableList) {
+ // NOOP
+ }
+
+ fun onPostBind(adapter: ModularAdapter, vh: T, pos: Int) {
+ // NOOP
+ }
+ }
+
+ interface Typing : Module {
+ fun onGetItemType(adapter: ModularAdapter<*>, pos: Int): Int?
+ }
+
+ interface ItemId : Module {
+ fun getItemId(adapter: ModularAdapter<*>, position: Int): Long?
+ }
+
+ interface RecyclerViewLifecycle : Module {
+ fun onDetachedFromRecyclerView(recyclerView: RecyclerView)
+ fun onAttachedToRecyclerView(recyclerView: RecyclerView)
+ }
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/common/lists/modular/mods/ClickMod.kt b/app/src/main/java/eu/darken/cap/common/lists/modular/mods/ClickMod.kt
new file mode 100644
index 00000000..301347a4
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/modular/mods/ClickMod.kt
@@ -0,0 +1,12 @@
+package eu.darken.cap.common.lists.modular.mods
+
+import eu.darken.cap.common.lists.modular.ModularAdapter
+
+class ClickMod constructor(
+ private val listener: (VHT, Int) -> Unit
+) : ModularAdapter.Module.Binder {
+
+ override fun onBindModularVH(adapter: ModularAdapter, vh: VHT, pos: Int, payloads: MutableList) {
+ vh.itemView.setOnClickListener { listener.invoke(vh, pos) }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/lists/modular/mods/DataBinderMod.kt b/app/src/main/java/eu/darken/cap/common/lists/modular/mods/DataBinderMod.kt
new file mode 100644
index 00000000..947c8f59
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/modular/mods/DataBinderMod.kt
@@ -0,0 +1,17 @@
+package eu.darken.cap.common.lists.modular.mods
+
+import androidx.viewbinding.ViewBinding
+import eu.darken.cap.common.lists.BindableVH
+import eu.darken.cap.common.lists.modular.ModularAdapter
+
+class DataBinderMod constructor(
+ private val data: List,
+ private val customBinder: (
+ (adapter: ModularAdapter, vh: HolderT, pos: Int, payload: MutableList) -> Unit
+ )? = null
+) : ModularAdapter.Module.Binder where HolderT : BindableVH, HolderT : ModularAdapter.VH {
+
+ override fun onBindModularVH(adapter: ModularAdapter, vh: HolderT, pos: Int, payloads: MutableList) {
+ customBinder?.invoke(adapter, vh, pos, mutableListOf()) ?: vh.bind(data[pos], payloads)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/lists/modular/mods/SimpleVHCreatorMod.kt b/app/src/main/java/eu/darken/cap/common/lists/modular/mods/SimpleVHCreatorMod.kt
new file mode 100644
index 00000000..1c814c8a
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/modular/mods/SimpleVHCreatorMod.kt
@@ -0,0 +1,15 @@
+package eu.darken.cap.common.lists.modular.mods
+
+import android.view.ViewGroup
+import eu.darken.cap.common.lists.modular.ModularAdapter
+
+class SimpleVHCreatorMod constructor(
+ private val viewType: Int = 0,
+ private val factory: (ViewGroup) -> HolderT
+) : ModularAdapter.Module.Creator where HolderT : ModularAdapter.VH {
+
+ override fun onCreateModularVH(adapter: ModularAdapter, parent: ViewGroup, viewType: Int): HolderT? {
+ if (this.viewType != viewType) return null
+ return factory.invoke(parent)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/lists/modular/mods/StableIdMod.kt b/app/src/main/java/eu/darken/cap/common/lists/modular/mods/StableIdMod.kt
new file mode 100644
index 00000000..8017b157
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/modular/mods/StableIdMod.kt
@@ -0,0 +1,21 @@
+package eu.darken.cap.common.lists.modular.mods
+
+import androidx.recyclerview.widget.RecyclerView
+import eu.darken.cap.common.lists.differ.DifferItem
+import eu.darken.cap.common.lists.modular.ModularAdapter
+
+class StableIdMod constructor(
+ private val data: List,
+ private val customResolver: (position: Int) -> Long = {
+ (data[it] as? DifferItem)?.stableId ?: RecyclerView.NO_ID
+ }
+) : ModularAdapter.Module.ItemId, ModularAdapter.Module.Setup {
+
+ override fun onAdapterReady(adapter: ModularAdapter<*>) {
+ adapter.setHasStableIds(true)
+ }
+
+ override fun getItemId(adapter: ModularAdapter<*>, position: Int): Long? {
+ return customResolver.invoke(position)
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/common/lists/modular/mods/TypedVHCreatorMod.kt b/app/src/main/java/eu/darken/cap/common/lists/modular/mods/TypedVHCreatorMod.kt
new file mode 100644
index 00000000..5f0cf16c
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/lists/modular/mods/TypedVHCreatorMod.kt
@@ -0,0 +1,29 @@
+package eu.darken.cap.common.lists.modular.mods
+
+import android.view.ViewGroup
+import eu.darken.cap.common.lists.modular.ModularAdapter
+
+class TypedVHCreatorMod constructor(
+ private val typeResolver: (Int) -> Boolean,
+ private val factory: (ViewGroup) -> HolderT
+) : ModularAdapter.Module.Typing,
+ ModularAdapter.Module.Creator where HolderT : ModularAdapter.VH {
+
+ private fun ModularAdapter<*>.determineOurViewType(): Int {
+ val typingModules = modules.filterIsInstance(ModularAdapter.Module.Typing::class.java)
+ return typingModules.indexOf(this@TypedVHCreatorMod)
+ }
+
+ override fun onGetItemType(adapter: ModularAdapter<*>, pos: Int): Int? {
+ return if (typeResolver.invoke(pos)) adapter.determineOurViewType() else null
+ }
+
+ override fun onCreateModularVH(
+ adapter: ModularAdapter,
+ parent: ViewGroup,
+ viewType: Int
+ ): HolderT? {
+ if (adapter.determineOurViewType() != viewType) return null
+ return factory.invoke(parent)
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/common/livedata/SingleLiveEvent.kt b/app/src/main/java/eu/darken/cap/common/livedata/SingleLiveEvent.kt
new file mode 100644
index 00000000..2fb97195
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/livedata/SingleLiveEvent.kt
@@ -0,0 +1,76 @@
+package eu.darken.cap.common.livedata
+
+/*
+ * Copyright 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import androidx.annotation.MainThread
+import androidx.annotation.Nullable
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Observer
+import eu.darken.cap.common.debug.logging.Logging.Priority.WARN
+import eu.darken.cap.common.debug.logging.log
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * A lifecycle-aware observable that sends only new updates after subscription, used for events like
+ * navigation and Snackbar messages.
+ *
+ *
+ * This avoids a common problem with events: on configuration change (like rotation) an update
+ * can be emitted if the observer is active. This LiveData only calls the observable if there's an
+ * explicit call to setValue() or call().
+ *
+ *
+ * Note that only one observer is going to be notified of changes.
+ * https://github.com/android/architecture-samples/blob/166ca3a93ad14c6e224a3ea9bfcbd773eb048fb0/todoapp/app/src/main/java/com/example/android/architecture/blueprints/todoapp/SingleLiveEvent.java
+ */
+class SingleLiveEvent : MutableLiveData() {
+
+ private val pending = AtomicBoolean(false)
+
+ @MainThread
+ override fun observe(owner: LifecycleOwner, observer: Observer) {
+ if (hasActiveObservers()) {
+ log(WARN) { "Multiple observers registered but only one will be notified of changes." }
+ }
+
+ // Observe the internal MutableLiveData
+ super.observe(
+ owner,
+ { t ->
+ if (pending.compareAndSet(true, false)) {
+ observer.onChanged(t)
+ }
+ }
+ )
+ }
+
+ @MainThread
+ override fun setValue(@Nullable t: T?) {
+ pending.set(true)
+ super.setValue(t)
+ }
+
+ /**
+ * Used for cases where T is Void, to make calls cleaner.
+ */
+ @MainThread
+ fun call() {
+ value = null
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/common/navigation/FragmentExtensions.kt b/app/src/main/java/eu/darken/cap/common/navigation/FragmentExtensions.kt
new file mode 100644
index 00000000..d8f26f89
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/navigation/FragmentExtensions.kt
@@ -0,0 +1,38 @@
+package eu.darken.cap.common.navigation
+
+import android.app.Activity
+import androidx.annotation.IdRes
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentContainerView
+import androidx.fragment.app.FragmentManager
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.fragment.findNavController
+import eu.darken.cap.common.debug.logging.Logging.Priority.WARN
+import eu.darken.cap.common.debug.logging.asLog
+import eu.darken.cap.common.debug.logging.log
+
+fun Fragment.doNavigate(direction: NavDirections) = findNavController().doNavigate(direction)
+
+fun Fragment.popBackStack(): Boolean {
+ if (!isAdded) {
+ IllegalStateException("Fragment is not added").also {
+ log(WARN) { "Trying to pop backstack on Fragment that isn't added to an Activity: ${it.asLog()}" }
+ }
+ return false
+ }
+ return findNavController().popBackStack()
+}
+
+/**
+ * [FragmentContainerView] does not access [NavController] in [Activity.onCreate]
+ * as workaround [FragmentManager] is used to get the [NavController]
+ * @param id [Int] NavFragment id
+ * @see issue-142847973
+ */
+@Throws(IllegalStateException::class)
+fun FragmentManager.findNavController(@IdRes id: Int): NavController {
+ val fragment = findFragmentById(id) ?: throw IllegalStateException("Fragment is not found for id:$id")
+ return NavHostFragment.findNavController(fragment)
+}
diff --git a/app/src/main/java/eu/darken/cap/common/navigation/NavArgsExtensions.kt b/app/src/main/java/eu/darken/cap/common/navigation/NavArgsExtensions.kt
new file mode 100644
index 00000000..9f568dc6
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/navigation/NavArgsExtensions.kt
@@ -0,0 +1,20 @@
+package eu.darken.cap.common.navigation
+
+import android.os.Bundle
+import android.os.Parcelable
+import androidx.lifecycle.SavedStateHandle
+import androidx.navigation.NavArgs
+import androidx.navigation.NavArgsLazy
+import java.io.Serializable
+
+// TODO Remove with "androidx.navigation:navigation-safe-args-gradle-plugin:2.4.0-alpha/stable"
+inline fun SavedStateHandle.navArgs() = NavArgsLazy(Args::class) {
+ Bundle().apply {
+ keys().forEach {
+ when (val value = get(it)) {
+ is Serializable -> putSerializable(it, value)
+ is Parcelable -> putParcelable(it, value)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/navigation/NavControllerExtensions.kt b/app/src/main/java/eu/darken/cap/common/navigation/NavControllerExtensions.kt
new file mode 100644
index 00000000..b676a9bd
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/navigation/NavControllerExtensions.kt
@@ -0,0 +1,22 @@
+package eu.darken.cap.common.navigation
+
+import android.os.Bundle
+import androidx.annotation.IdRes
+import androidx.navigation.NavController
+import androidx.navigation.NavDirections
+
+fun NavController.navigateIfNotThere(@IdRes resId: Int, args: Bundle? = null) {
+ if (currentDestination?.id == resId) return
+ navigate(resId, args)
+}
+
+fun NavController.doNavigate(direction: NavDirections) {
+ currentDestination?.getAction(direction.actionId)?.let { navigate(direction) }
+}
+
+fun NavController.isGraphSet(): Boolean = try {
+ graph
+ true
+} catch (e: IllegalStateException) {
+ false
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/navigation/NavDestinationExtensions.kt b/app/src/main/java/eu/darken/cap/common/navigation/NavDestinationExtensions.kt
new file mode 100644
index 00000000..7384a568
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/navigation/NavDestinationExtensions.kt
@@ -0,0 +1,9 @@
+package eu.darken.cap.common.navigation
+
+import androidx.annotation.IdRes
+import androidx.navigation.NavDestination
+
+fun NavDestination?.hasAction(@IdRes id: Int): Boolean {
+ if (this == null) return false
+ return getAction(id) != null
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/navigation/NavDirectionsExtensions.kt b/app/src/main/java/eu/darken/cap/common/navigation/NavDirectionsExtensions.kt
new file mode 100644
index 00000000..d7d32c56
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/navigation/NavDirectionsExtensions.kt
@@ -0,0 +1,13 @@
+package eu.darken.cap.common.navigation
+
+import androidx.lifecycle.MutableLiveData
+import androidx.navigation.NavDirections
+import eu.darken.cap.common.livedata.SingleLiveEvent
+
+fun NavDirections.navVia(pub: MutableLiveData) = pub.postValue(this)
+
+fun NavDirections.navVia(provider: NavEventSource) = this.navVia(provider.navEvents)
+
+interface NavEventSource {
+ val navEvents: SingleLiveEvent
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/permissions/Permission.kt b/app/src/main/java/eu/darken/cap/common/permissions/Permission.kt
new file mode 100644
index 00000000..5665af56
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/permissions/Permission.kt
@@ -0,0 +1,32 @@
+package eu.darken.cap.common.permissions
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.annotation.StringRes
+import androidx.core.content.ContextCompat
+import eu.darken.cap.R
+
+enum class Permission(
+ val minApiLevel: Int,
+ @StringRes val labelRes: Int,
+ @StringRes val descriptionRes: Int,
+ val permissionId: String,
+) {
+ BLUETOOTH_CONNECT(
+ minApiLevel = Build.VERSION_CODES.S,
+ labelRes = R.string.permission_bluetooth_connect_label,
+ descriptionRes = R.string.permission_bluetooth_connect_description,
+ permissionId = "android.permission.BLUETOOTH_CONNECT",
+ ),
+ BLUETOOTH_SCAN(
+ minApiLevel = Build.VERSION_CODES.S,
+ labelRes = R.string.permission_bluetooth_scan_label,
+ descriptionRes = R.string.permission_bluetooth_scan_description,
+ permissionId = "android.permission.BLUETOOTH_SCAN",
+ )
+}
+
+fun Permission.isGranted(context: Context): Boolean {
+ return ContextCompat.checkSelfPermission(context, permissionId) == PackageManager.PERMISSION_GRANTED
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/preferences/FlowPreference.kt b/app/src/main/java/eu/darken/cap/common/preferences/FlowPreference.kt
new file mode 100644
index 00000000..bd5cc641
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/preferences/FlowPreference.kt
@@ -0,0 +1,90 @@
+package eu.darken.cap.common.preferences
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import eu.darken.cap.common.debug.logging.Logging.Priority.*
+import eu.darken.cap.common.debug.logging.log
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class FlowPreference constructor(
+ private val preferences: SharedPreferences,
+ private val key: String,
+ private val reader: SharedPreferences.(key: String) -> T,
+ private val writer: SharedPreferences.Editor.(key: String, value: T) -> Unit
+) {
+
+ private val flowInternal = MutableStateFlow(internalValue)
+ val flow: Flow = flowInternal
+
+ private val preferenceChangeListener =
+ SharedPreferences.OnSharedPreferenceChangeListener { changedPrefs, changedKey ->
+ if (changedKey != key) return@OnSharedPreferenceChangeListener
+
+ val newValue = reader(changedPrefs, changedKey)
+ val currentvalue = flowInternal.value
+ if (currentvalue != newValue && flowInternal.compareAndSet(currentvalue, newValue)) {
+ log(VERBOSE) { "$changedPrefs:$changedKey changed to $newValue" }
+ }
+ }
+
+ init {
+ preferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
+ }
+
+ private var internalValue: T
+ get() = reader(preferences, key)
+ set(newValue) {
+ preferences.edit {
+ writer(key, newValue)
+ }
+ flowInternal.value = internalValue
+ }
+ val value: T
+ get() = internalValue
+
+ fun update(update: (T) -> T) {
+ internalValue = update(internalValue)
+ }
+
+ companion object {
+ inline fun basicReader(defaultValue: T): SharedPreferences.(key: String) -> T =
+ { key ->
+ (this.all[key] ?: defaultValue) as T
+ }
+
+ inline fun basicWriter(): SharedPreferences.Editor.(key: String, value: T) -> Unit =
+ { key, value ->
+ when (value) {
+ is Boolean -> putBoolean(key, value)
+ is String -> putString(key, value)
+ is Int -> putInt(key, value)
+ is Long -> putLong(key, value)
+ is Float -> putFloat(key, value)
+ null -> remove(key)
+ else -> throw NotImplementedError()
+ }
+ }
+ }
+}
+
+inline fun SharedPreferences.createFlowPreference(
+ key: String,
+ defaultValue: T = null as T
+) = FlowPreference(
+ preferences = this,
+ key = key,
+ reader = FlowPreference.basicReader(defaultValue),
+ writer = FlowPreference.basicWriter()
+)
+
+inline fun SharedPreferences.createFlowPreference(
+ key: String,
+ noinline reader: SharedPreferences.(key: String) -> T,
+ noinline writer: SharedPreferences.Editor.(key: String, value: T) -> Unit
+) = FlowPreference(
+ preferences = this,
+ key = key,
+ reader = reader,
+ writer = writer
+)
diff --git a/app/src/main/java/eu/darken/cap/common/preferences/SharedPreferenceExtensions.kt b/app/src/main/java/eu/darken/cap/common/preferences/SharedPreferenceExtensions.kt
new file mode 100644
index 00000000..8e580e20
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/preferences/SharedPreferenceExtensions.kt
@@ -0,0 +1,16 @@
+package eu.darken.cap.common.preferences
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
+import eu.darken.cap.common.debug.logging.log
+
+fun SharedPreferences.clearAndNotify() {
+ val currentKeys = this.all.keys.toSet()
+ log(VERBOSE) { "$this clearAndNotify(): $currentKeys" }
+ edit {
+ currentKeys.forEach { remove(it) }
+ }
+ // Clear does not notify anyone using registerOnSharedPreferenceChangeListener
+ edit(commit = true) { clear() }
+}
diff --git a/app/src/main/java/eu/darken/cap/common/smart/Smart2BottomSheetDialogFragment.kt b/app/src/main/java/eu/darken/cap/common/smart/Smart2BottomSheetDialogFragment.kt
new file mode 100644
index 00000000..0f209156
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/smart/Smart2BottomSheetDialogFragment.kt
@@ -0,0 +1,95 @@
+package eu.darken.cap.common.smart
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.lifecycle.LiveData
+import androidx.viewbinding.ViewBinding
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+import eu.darken.cap.common.error.asErrorDialogBuilder
+import eu.darken.cap.common.navigation.doNavigate
+import eu.darken.cap.common.navigation.popBackStack
+import eu.darken.cap.common.observe2
+
+
+abstract class Smart2BottomSheetDialogFragment : BottomSheetDialogFragment() {
+
+ abstract val ui: ViewBinding
+ abstract val vdc: Smart2VM
+
+ internal val tag: String =
+ logTag("Fragment", "${this.javaClass.simpleName}(${Integer.toHexString(hashCode())})")
+
+ override fun onAttach(context: Context) {
+ log(tag, VERBOSE) { "onAttach(context=$context)" }
+ super.onAttach(context)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ log(tag, VERBOSE) { "onCreate(savedInstanceState=$savedInstanceState)" }
+ super.onCreate(savedInstanceState)
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ log(tag, VERBOSE) {
+ "onCreateView(inflater=$inflater, container=$container, savedInstanceState=$savedInstanceState"
+ }
+ return super.onCreateView(inflater, container, savedInstanceState)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ log(tag, VERBOSE) { "onViewCreated(view=$view, savedInstanceState=$savedInstanceState)" }
+ super.onViewCreated(view, savedInstanceState)
+
+ vdc.navEvents.observe2(this, ui) { dir -> dir?.let { doNavigate(it) } ?: popBackStack() }
+ vdc.errorEvents.observe2(this, ui) { it.asErrorDialogBuilder(requireContext()).show() }
+ }
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ log(tag, VERBOSE) { "onActivityCreated(savedInstanceState=$savedInstanceState)" }
+ super.onActivityCreated(savedInstanceState)
+ }
+
+ override fun onResume() {
+ log(tag, VERBOSE) { "onResume()" }
+ super.onResume()
+ }
+
+ override fun onPause() {
+ log(tag, VERBOSE) { "onPause()" }
+ super.onPause()
+ }
+
+ override fun onDestroyView() {
+ log(tag, VERBOSE) { "onDestroyView()" }
+ super.onDestroyView()
+ }
+
+ override fun onDetach() {
+ log(tag, VERBOSE) { "onDetach()" }
+ super.onDetach()
+ }
+
+ override fun onDestroy() {
+ log(tag, VERBOSE) { "onDestroy()" }
+ super.onDestroy()
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ log(tag, VERBOSE) { "onActivityResult(requestCode=$requestCode, resultCode=$resultCode, data=$data)" }
+ super.onActivityResult(requestCode, resultCode, data)
+ }
+
+ inline fun LiveData.observe2(
+ ui: VB,
+ crossinline callback: VB.(T) -> Unit
+ ) {
+ observe(viewLifecycleOwner) { callback.invoke(ui, it) }
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/common/smart/Smart2Fragment.kt b/app/src/main/java/eu/darken/cap/common/smart/Smart2Fragment.kt
new file mode 100644
index 00000000..4cd94e9b
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/smart/Smart2Fragment.kt
@@ -0,0 +1,53 @@
+package eu.darken.cap.common.smart
+
+import android.os.Bundle
+import android.view.View
+import androidx.annotation.LayoutRes
+import androidx.lifecycle.LiveData
+import androidx.viewbinding.ViewBinding
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.error.asErrorDialogBuilder
+import eu.darken.cap.common.navigation.doNavigate
+import eu.darken.cap.common.navigation.popBackStack
+
+
+abstract class Smart2Fragment(@LayoutRes layoutRes: Int?) : SmartFragment(layoutRes) {
+
+ constructor() : this(null)
+
+ abstract val ui: ViewBinding?
+ abstract val vm: Smart2VM
+
+ var onErrorEvent: ((Throwable) -> Boolean)? = null
+
+ var onFinishEvent: (() -> Unit)? = null
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ vm.navEvents.observe2(ui) {
+ log { "navEvents: $it" }
+
+ it?.run { doNavigate(this) } ?: onFinishEvent?.invoke() ?: popBackStack()
+ }
+
+ vm.errorEvents.observe2(ui) {
+ val showDialog = onErrorEvent?.invoke(it) ?: true
+ if (showDialog) it.asErrorDialogBuilder(requireContext()).show()
+ }
+ }
+
+ inline fun LiveData.observe2(
+ crossinline callback: (T) -> Unit
+ ) {
+ observe(viewLifecycleOwner) { callback.invoke(it) }
+ }
+
+ inline fun LiveData.observe2(
+ ui: VB,
+ crossinline callback: VB.(T) -> Unit
+ ) {
+ observe(viewLifecycleOwner) { callback.invoke(ui, it) }
+ }
+
+}
diff --git a/app/src/main/java/eu/darken/cap/common/smart/Smart2VM.kt b/app/src/main/java/eu/darken/cap/common/smart/Smart2VM.kt
new file mode 100644
index 00000000..abad88d8
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/smart/Smart2VM.kt
@@ -0,0 +1,34 @@
+package eu.darken.cap.common.smart
+
+import androidx.navigation.NavDirections
+import eu.darken.cap.common.coroutine.DispatcherProvider
+import eu.darken.cap.common.debug.logging.asLog
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.error.ErrorEventSource
+import eu.darken.cap.common.flow.setupCommonEventHandlers
+import eu.darken.cap.common.livedata.SingleLiveEvent
+import eu.darken.cap.common.navigation.NavEventSource
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.launchIn
+
+
+abstract class Smart2VM(
+ dispatcherProvider: DispatcherProvider,
+) : SmartVM(dispatcherProvider), NavEventSource, ErrorEventSource {
+
+ override val navEvents = SingleLiveEvent()
+ override val errorEvents = SingleLiveEvent()
+
+ init {
+ launchErrorHandler = CoroutineExceptionHandler { _, ex ->
+ log(TAG) { "Error during launch: ${ex.asLog()}" }
+ errorEvents.postValue(ex)
+ }
+ }
+
+ override fun Flow.launchInViewModel() = this
+ .setupCommonEventHandlers(TAG) { "launchInViewModel()" }
+ .launchIn(vmScope)
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/smart/SmartActivity.kt b/app/src/main/java/eu/darken/cap/common/smart/SmartActivity.kt
new file mode 100644
index 00000000..2f564a76
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/smart/SmartActivity.kt
@@ -0,0 +1,39 @@
+package eu.darken.cap.common.smart
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+
+abstract class SmartActivity : AppCompatActivity() {
+ internal val tag: String =
+ logTag("Activity", this.javaClass.simpleName + "(" + Integer.toHexString(hashCode()) + ")")
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ log(tag, VERBOSE) { "onCreate(savedInstanceState=$savedInstanceState)" }
+ super.onCreate(savedInstanceState)
+ }
+
+ override fun onResume() {
+ log(tag, VERBOSE) { "onResume()" }
+ super.onResume()
+ }
+
+ override fun onPause() {
+ log(tag, VERBOSE) { "onPause()" }
+ super.onPause()
+ }
+
+ override fun onDestroy() {
+ log(tag, VERBOSE) { "onDestroy()" }
+ super.onDestroy()
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ log(tag, VERBOSE) { "onActivityResult(requestCode=$requestCode, resultCode=$resultCode, data=$data)" }
+ super.onActivityResult(requestCode, resultCode, data)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/smart/SmartFragment.kt b/app/src/main/java/eu/darken/cap/common/smart/SmartFragment.kt
new file mode 100644
index 00000000..b63d979b
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/smart/SmartFragment.kt
@@ -0,0 +1,80 @@
+package eu.darken.cap.common.smart
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.LayoutRes
+import androidx.fragment.app.Fragment
+import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+
+
+abstract class SmartFragment(@LayoutRes val layoutRes: Int?) : Fragment(layoutRes ?: 0) {
+
+ constructor() : this(null)
+
+ internal val tag: String =
+ logTag("Fragment", "${this.javaClass.simpleName}(${Integer.toHexString(hashCode())})")
+
+ override fun onAttach(context: Context) {
+ log(tag, VERBOSE) { "onAttach(context=$context)" }
+ super.onAttach(context)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ log(tag, VERBOSE) { "onCreate(savedInstanceState=$savedInstanceState)" }
+ super.onCreate(savedInstanceState)
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ log(tag, VERBOSE) {
+ "onCreateView(inflater=$inflater, container=$container, savedInstanceState=$savedInstanceState"
+ }
+ return layoutRes?.let { inflater.inflate(it, container, false) }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ log(tag, VERBOSE) { "onViewCreated(view=$view, savedInstanceState=$savedInstanceState)" }
+ super.onViewCreated(view, savedInstanceState)
+ }
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ log(tag, VERBOSE) { "onActivityCreated(savedInstanceState=$savedInstanceState)" }
+ super.onActivityCreated(savedInstanceState)
+ }
+
+ override fun onResume() {
+ log(tag, VERBOSE) { "onResume()" }
+ super.onResume()
+ }
+
+ override fun onPause() {
+ log(tag, VERBOSE) { "onPause()" }
+ super.onPause()
+ }
+
+ override fun onDestroyView() {
+ log(tag, VERBOSE) { "onDestroyView()" }
+ super.onDestroyView()
+ }
+
+ override fun onDetach() {
+ log(tag, VERBOSE) { "onDetach()" }
+ super.onDetach()
+ }
+
+ override fun onDestroy() {
+ log(tag, VERBOSE) { "onDestroy()" }
+ super.onDestroy()
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ log(tag, VERBOSE) { "onActivityResult(requestCode=$requestCode, resultCode=$resultCode, data=$data)" }
+ super.onActivityResult(requestCode, resultCode, data)
+ }
+
+}
diff --git a/app/src/main/java/eu/darken/cap/common/smart/SmartService.kt b/app/src/main/java/eu/darken/cap/common/smart/SmartService.kt
new file mode 100644
index 00000000..a8645012
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/smart/SmartService.kt
@@ -0,0 +1,52 @@
+package eu.darken.cap.common.smart
+
+import android.app.Service
+import android.content.Intent
+import android.content.res.Configuration
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+
+abstract class SmartService : Service() {
+ private val tag: String =
+ logTag("Service", this.javaClass.simpleName + "(" + Integer.toHexString(this.hashCode()) + ")")
+
+ override fun onCreate() {
+ log(tag) { "onCreate()" }
+ super.onCreate()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ log(tag) { "onStartCommand(intent=$intent, flags=$flags startId=$startId)" }
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ override fun onDestroy() {
+ log(tag) { "onDestroy()" }
+ super.onDestroy()
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ log(tag) { "onConfigurationChanged(newConfig=$newConfig)" }
+ super.onConfigurationChanged(newConfig)
+ }
+
+ override fun onLowMemory() {
+ log(tag) { "onLowMemory()" }
+ super.onLowMemory()
+ }
+
+ override fun onUnbind(intent: Intent): Boolean {
+ log(tag) { "onUnbind(intent=$intent)" }
+ return super.onUnbind(intent)
+ }
+
+ override fun onRebind(intent: Intent) {
+ log(tag) { "onRebind(intent=$intent)" }
+ super.onRebind(intent)
+ }
+
+ override fun onTaskRemoved(rootIntent: Intent) {
+ log(tag) { "onTaskRemoved(rootIntent=$rootIntent)" }
+ super.onTaskRemoved(rootIntent)
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/common/smart/SmartVM.kt b/app/src/main/java/eu/darken/cap/common/smart/SmartVM.kt
new file mode 100644
index 00000000..2f8afeb0
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/smart/SmartVM.kt
@@ -0,0 +1,64 @@
+package eu.darken.cap.common.smart
+
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.viewModelScope
+import eu.darken.cap.common.coroutine.DefaultDispatcherProvider
+import eu.darken.cap.common.coroutine.DispatcherProvider
+import eu.darken.cap.common.debug.logging.Logging.Priority.WARN
+import eu.darken.cap.common.debug.logging.asLog
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.error.ErrorEventSource
+import eu.darken.cap.common.flow.DynamicStateFlow
+import eu.darken.cap.common.viewmodel.VM
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.launchIn
+import kotlin.coroutines.CoroutineContext
+
+
+abstract class SmartVM(
+ private val dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider(),
+) : VM() {
+
+ val vmScope = viewModelScope + dispatcherProvider.Default
+
+ var launchErrorHandler: CoroutineExceptionHandler? = null
+
+ private fun getVDCContext(): CoroutineContext {
+ val dispatcher = dispatcherProvider.Default
+ return getErrorHandler()?.let { dispatcher + it } ?: dispatcher
+ }
+
+ private fun getErrorHandler(): CoroutineExceptionHandler? {
+ val handler = launchErrorHandler
+ if (handler != null) return handler
+
+ if (this is ErrorEventSource) {
+ return CoroutineExceptionHandler { _, ex ->
+ log(WARN) { "Error during launch: ${ex.asLog()}" }
+ errorEvents.postValue(ex)
+ }
+ }
+
+ return null
+ }
+
+ fun DynamicStateFlow.asLiveData2() = flow.asLiveData2()
+
+ fun Flow.asLiveData2() = this.asLiveData(context = getVDCContext())
+
+ fun launch(
+ scope: CoroutineScope = viewModelScope,
+ context: CoroutineContext = getVDCContext(),
+ block: suspend CoroutineScope.() -> Unit
+ ) {
+ try {
+ scope.launch(context = context, block = block)
+ } catch (e: CancellationException) {
+ log(TAG, WARN) { "launch()ed coroutine was canceled (scope=$scope): ${e.asLog()}" }
+ }
+ }
+
+ open fun Flow.launchInViewModel() = this.launchIn(vmScope)
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/viewbinding/ViewBindingExtensions.kt b/app/src/main/java/eu/darken/cap/common/viewbinding/ViewBindingExtensions.kt
new file mode 100644
index 00000000..a2ace067
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/viewbinding/ViewBindingExtensions.kt
@@ -0,0 +1,93 @@
+package eu.darken.cap.common.viewbinding
+
+import android.os.Handler
+import android.os.Looper
+import android.view.View
+import androidx.annotation.MainThread
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.viewbinding.ViewBinding
+import eu.darken.cap.common.debug.logging.Logging.Priority.*
+import eu.darken.cap.common.debug.logging.log
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+
+inline fun FragmentT.viewBinding() =
+ this.viewBinding(
+ bindingProvider = {
+ val bindingMethod = BindingT::class.java.getMethod("bind", View::class.java)
+ bindingMethod(null, requireView()) as BindingT
+ },
+ lifecycleOwnerProvider = { viewLifecycleOwner }
+ )
+
+@Suppress("unused")
+fun FragmentT.viewBinding(
+ bindingProvider: FragmentT.() -> BindingT,
+ lifecycleOwnerProvider: FragmentT.() -> LifecycleOwner
+) = ViewBindingProperty(bindingProvider, lifecycleOwnerProvider)
+
+class ViewBindingProperty(
+ private val bindingProvider: (ComponentT) -> BindingT,
+ private val lifecycleOwnerProvider: ComponentT.() -> LifecycleOwner
+) : ReadOnlyProperty {
+
+ private val uiHandler = Handler(Looper.getMainLooper())
+ private var localRef: ComponentT? = null
+ private var viewBinding: BindingT? = null
+
+ private val onDestroyObserver = object : DefaultLifecycleObserver {
+ // Called right before Fragment.onDestroyView
+ override fun onDestroy(owner: LifecycleOwner) {
+ localRef?.lifecycle?.removeObserver(this) ?: return
+
+ localRef = null
+
+ uiHandler.post {
+ log(VERBOSE) { "Resetting viewBinding" }
+ viewBinding = null
+ }
+ }
+ }
+
+ @MainThread
+ override fun getValue(thisRef: ComponentT, property: KProperty<*>): BindingT {
+ if (localRef == null && viewBinding != null) {
+ log(WARN) { "Fragment.onDestroyView() was called, but the handler didn't execute our delayed reset." }
+ /**
+ * There is a fragment racecondition if you navigate to another fragment and quickly popBackStack().
+ * Our uiHandler.post { } will not have executed for some reason.
+ * In that case we manually null the old viewBinding, to allow for clean recreation.
+ */
+ viewBinding = null
+ }
+
+ /**
+ * When quickly navigating, a fragment may be created that was never visible to the user.
+ * It's possible that [Fragment.onDestroyView] is called, but [DefaultLifecycleObserver.onDestroy] is not.
+ * This means the ViewBinding will is not be set to `null` and it still holds the previous layout,
+ * instead of the new layout that the Fragment inflated when navigating back to it.
+ */
+ (localRef as? Fragment)?.view?.let {
+ if (it != viewBinding?.root && localRef === thisRef) {
+ log(WARN) { "Different view for the same fragment, resetting old viewBinding." }
+ viewBinding = null
+ }
+ }
+
+ viewBinding?.let {
+ // Only accessible from within the same component
+ require(localRef === thisRef)
+ return@getValue it
+ }
+
+ val lifecycle = lifecycleOwnerProvider(thisRef).lifecycle
+
+ return bindingProvider(thisRef).also {
+ viewBinding = it
+ localRef = thisRef
+ lifecycle.addObserver(onDestroyObserver)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/viewmodel/SmartVM.kt b/app/src/main/java/eu/darken/cap/common/viewmodel/SmartVM.kt
new file mode 100644
index 00000000..2fc1154a
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/viewmodel/SmartVM.kt
@@ -0,0 +1,15 @@
+package eu.darken.cap.common.viewmodel
+
+import androidx.lifecycle.asLiveData
+import eu.darken.cap.common.coroutine.DefaultDispatcherProvider
+import eu.darken.cap.common.coroutine.DispatcherProvider
+import kotlinx.coroutines.flow.Flow
+
+
+abstract class SmartVM(
+ private val dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider(),
+) : VM() {
+
+ fun Flow.asLiveData2() = this.asLiveData(context = dispatcherProvider.Default)
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/viewmodel/VM.kt b/app/src/main/java/eu/darken/cap/common/viewmodel/VM.kt
new file mode 100644
index 00000000..542a73f0
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/viewmodel/VM.kt
@@ -0,0 +1,20 @@
+package eu.darken.cap.common.viewmodel
+
+import androidx.annotation.CallSuper
+import androidx.lifecycle.ViewModel
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+
+abstract class VM : ViewModel() {
+ val TAG: String = logTag("VM", javaClass.simpleName)
+
+ init {
+ log(TAG) { "Initialized" }
+ }
+
+ @CallSuper
+ override fun onCleared() {
+ log(TAG) { "onCleared()" }
+ super.onCleared()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/viewmodel/ViewModelLazyKeyed.kt b/app/src/main/java/eu/darken/cap/common/viewmodel/ViewModelLazyKeyed.kt
new file mode 100644
index 00000000..bf9cd15e
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/viewmodel/ViewModelLazyKeyed.kt
@@ -0,0 +1,174 @@
+package eu.darken.cap.common.viewmodel
+
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+import androidx.activity.ComponentActivity
+import androidx.annotation.MainThread
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.*
+import kotlin.reflect.KClass
+
+/**
+ * Returns an existing ViewModel or creates a new one in the scope (usually, a fragment or
+ * an activity), associated with this `ViewModelProvider`.
+ *
+ * @see ViewModelProvider.get(Class)
+ */
+//@MainThread
+//inline fun ViewModelProvider.get() = get(VM::class.java)
+
+/**
+ * An implementation of [Lazy] used by [androidx.fragment.app.Fragment.viewModels] and
+ * [androidx.activity.ComponentActivity.viewmodels].
+ *
+ * [storeProducer] is a lambda that will be called during initialization, [VM] will be created
+ * in the scope of returned [ViewModelStore].
+ *
+ * [factoryProducer] is a lambda that will be called during initialization,
+ * returned [ViewModelProvider.Factory] will be used for creation of [VM]
+ */
+class ViewModelLazyKeyed(
+ private val viewModelClass: KClass,
+ private val keyProducer: (() -> String)? = null,
+ private val storeProducer: () -> ViewModelStore,
+ private val factoryProducer: () -> ViewModelProvider.Factory
+) : Lazy {
+ private var cached: VM? = null
+
+ override val value: VM
+ get() {
+ val viewModel = cached
+ return if (viewModel == null) {
+ val factory = factoryProducer()
+ val store = storeProducer()
+ val key = keyProducer?.invoke() ?: "androidx.lifecycle.ViewModelProvider.DefaultKey"
+ ViewModelProvider(store, factory).get(
+ key + ":" + viewModelClass.java.canonicalName,
+ viewModelClass.java
+ ).also {
+ cached = it
+ }
+ } else {
+ viewModel
+ }
+ }
+
+ override fun isInitialized() = cached != null
+}
+
+/**
+ * Returns a property delegate to access [ViewModel] by **default** scoped to this [Fragment]:
+ * ```
+ * class MyFragment : Fragment() {
+ * val viewmodel: NYViewModel by viewmodels()
+ * }
+ * ```
+ *
+ * Custom [ViewModelProvider.Factory] can be defined via [factoryProducer] parameter,
+ * factory returned by it will be used to create [ViewModel]:
+ * ```
+ * class MyFragment : Fragment() {
+ * val viewmodel: MYViewModel by viewmodels { myFactory }
+ * }
+ * ```
+ *
+ * Default scope may be overridden with parameter [ownerProducer]:
+ * ```
+ * class MyFragment : Fragment() {
+ * val viewmodel: MYViewModel by viewmodels ({requireParentFragment()})
+ * }
+ * ```
+ *
+ * This property can be accessed only after this Fragment is attached i.e., after
+ * [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException.
+ */
+@MainThread
+inline fun Fragment.viewModelsKeyed(
+ noinline keyProducer: (() -> String)? = null,
+ noinline ownerProducer: () -> ViewModelStoreOwner = { this },
+ noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
+) = createViewModelLazyKeyed(VM::class, keyProducer, { ownerProducer().viewModelStore }, factoryProducer)
+
+/**
+ * Returns a property delegate to access parent activity's [ViewModel],
+ * if [factoryProducer] is specified then [ViewModelProvider.Factory]
+ * returned by it will be used to create [ViewModel] first time.
+ *
+ * ```
+ * class MyFragment : Fragment() {
+ * val viewmodel: MyViewModel by activityViewModels()
+ * }
+ * ```
+ *
+ * This property can be accessed only after this Fragment is attached i.e., after
+ * [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException.
+ */
+@MainThread
+inline fun Fragment.activityViewModelsKeyed(
+ noinline keyProducer: (() -> String)? = null,
+ noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
+) = createViewModelLazyKeyed(VM::class, keyProducer, { requireActivity().viewModelStore }, factoryProducer)
+
+/**
+ * Helper method for creation of [ViewModelLazy], that resolves `null` passed as [factoryProducer]
+ * to default factory.
+ */
+@MainThread
+fun Fragment.createViewModelLazyKeyed(
+ viewModelClass: KClass,
+ keyProducer: (() -> String)? = null,
+ storeProducer: () -> ViewModelStore,
+ factoryProducer: (() -> ViewModelProvider.Factory)? = null
+): Lazy {
+ val factoryPromise = factoryProducer ?: {
+ val application = activity?.application ?: throw IllegalStateException(
+ "ViewModel can be accessed only when Fragment is attached"
+ )
+ ViewModelProvider.AndroidViewModelFactory.getInstance(application)
+ }
+ return ViewModelLazyKeyed(viewModelClass, keyProducer, storeProducer, factoryPromise)
+}
+
+/**
+ * Returns a [Lazy] delegate to access the ComponentActivity's ViewModel, if [factoryProducer]
+ * is specified then [ViewModelProvider.Factory] returned by it will be used
+ * to create [ViewModel] first time.
+ *
+ * ```
+ * class MyComponentActivity : ComponentActivity() {
+ * val viewmodel: MyViewModel by viewmodels()
+ * }
+ * ```
+ *
+ * This property can be accessed only after the Activity is attached to the Application,
+ * and access prior to that will result in IllegalArgumentException.
+ */
+@MainThread
+inline fun ComponentActivity.viewModelsKeyed(
+ noinline keyProducer: (() -> String)? = null,
+ noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
+): Lazy {
+ val factoryPromise = factoryProducer ?: {
+ val application = application ?: throw IllegalArgumentException(
+ "ViewModel can be accessed only when Activity is attached"
+ )
+ ViewModelProvider.AndroidViewModelFactory.getInstance(application)
+ }
+
+ return ViewModelLazyKeyed(VM::class, keyProducer, { viewModelStore }, factoryPromise)
+}
diff --git a/app/src/main/java/eu/darken/cap/common/worker/WorkerExtensions.kt b/app/src/main/java/eu/darken/cap/common/worker/WorkerExtensions.kt
new file mode 100644
index 00000000..b9c3d7bb
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/worker/WorkerExtensions.kt
@@ -0,0 +1,31 @@
+package eu.darken.cap.common.worker
+
+import android.os.Parcel
+import android.os.Parcelable
+import androidx.work.Data
+
+@Suppress("UNCHECKED_CAST")
+inline fun Data.getParcelable(key: String): T? {
+ val parcel = Parcel.obtain()
+ try {
+ val bytes = getByteArray(key) ?: return null
+ parcel.unmarshall(bytes, 0, bytes.size)
+ parcel.setDataPosition(0)
+ val creator = T::class.java.getField("CREATOR").get(null) as Parcelable.Creator
+ return creator.createFromParcel(parcel)
+ } finally {
+ parcel.recycle()
+ }
+}
+
+
+fun Data.Builder.putParcelable(key: String, parcelable: Parcelable): Data.Builder {
+ val parcel = Parcel.obtain()
+ try {
+ parcelable.writeToParcel(parcel, 0)
+ putByteArray(key, parcel.marshall())
+ } finally {
+ parcel.recycle()
+ }
+ return this
+}
diff --git a/app/src/main/java/eu/darken/cap/main/ui/MainActivity.kt b/app/src/main/java/eu/darken/cap/main/ui/MainActivity.kt
new file mode 100644
index 00000000..343f3c9e
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/main/ui/MainActivity.kt
@@ -0,0 +1,24 @@
+package eu.darken.cap.main.ui
+
+import android.os.Bundle
+import androidx.activity.viewModels
+import dagger.hilt.android.AndroidEntryPoint
+import eu.darken.cap.R
+import eu.darken.cap.common.navigation.findNavController
+import eu.darken.cap.common.smart.SmartActivity
+import eu.darken.cap.databinding.MainActivityBinding
+
+@AndroidEntryPoint
+class MainActivity : SmartActivity() {
+
+ private val vm: MainActivityVM by viewModels()
+ private lateinit var ui: MainActivityBinding
+ private val navController by lazy { supportFragmentManager.findNavController(R.id.nav_host) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ ui = MainActivityBinding.inflate(layoutInflater)
+ setContentView(ui.root)
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/main/ui/MainActivityVM.kt b/app/src/main/java/eu/darken/cap/main/ui/MainActivityVM.kt
new file mode 100644
index 00000000..c0e53dab
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/main/ui/MainActivityVM.kt
@@ -0,0 +1,16 @@
+package eu.darken.cap.main.ui
+
+import androidx.lifecycle.SavedStateHandle
+import dagger.hilt.android.lifecycle.HiltViewModel
+import eu.darken.cap.common.coroutine.DispatcherProvider
+import eu.darken.cap.common.viewmodel.SmartVM
+import javax.inject.Inject
+
+
+@HiltViewModel
+class MainActivityVM @Inject constructor(
+ handle: SavedStateHandle,
+ dispatcherProvider: DispatcherProvider,
+) : SmartVM(dispatcherProvider = dispatcherProvider) {
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/main/ui/MainAdapter.kt b/app/src/main/java/eu/darken/cap/main/ui/MainAdapter.kt
new file mode 100644
index 00000000..4dba51bb
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/main/ui/MainAdapter.kt
@@ -0,0 +1,39 @@
+package eu.darken.cap.main.ui
+
+import android.view.ViewGroup
+import androidx.annotation.LayoutRes
+import androidx.viewbinding.ViewBinding
+import eu.darken.cap.common.lists.BindableVH
+import eu.darken.cap.common.lists.differ.AsyncDiffer
+import eu.darken.cap.common.lists.differ.DifferItem
+import eu.darken.cap.common.lists.differ.HasAsyncDiffer
+import eu.darken.cap.common.lists.differ.setupDiffer
+import eu.darken.cap.common.lists.modular.ModularAdapter
+import eu.darken.cap.common.lists.modular.mods.DataBinderMod
+import eu.darken.cap.common.lists.modular.mods.TypedVHCreatorMod
+import eu.darken.cap.main.ui.cards.PermissionCardVH
+import eu.darken.cap.main.ui.cards.ToggleCardVH
+import javax.inject.Inject
+
+class MainAdapter @Inject constructor() :
+ ModularAdapter>(),
+ HasAsyncDiffer {
+
+ override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer()
+
+ init {
+ modules.add(DataBinderMod(data))
+ modules.add(TypedVHCreatorMod({ data[it] is ToggleCardVH.Item }) { ToggleCardVH(it) })
+ modules.add(TypedVHCreatorMod({ data[it] is PermissionCardVH.Item }) { PermissionCardVH(it) })
+ }
+
+ override fun getItemCount(): Int = data.size
+
+ abstract class BaseVH(
+ @LayoutRes layoutId: Int,
+ parent: ViewGroup
+ ) : ModularAdapter.VH(layoutId, parent), BindableVH
+
+ interface Item : DifferItem
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/main/ui/MainFragment.kt b/app/src/main/java/eu/darken/cap/main/ui/MainFragment.kt
new file mode 100644
index 00000000..fbdc461d
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/main/ui/MainFragment.kt
@@ -0,0 +1,56 @@
+package eu.darken.cap.main.ui
+
+import android.os.Bundle
+import android.view.View
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.fragment.app.viewModels
+import dagger.hilt.android.AndroidEntryPoint
+import eu.darken.cap.R
+import eu.darken.cap.common.BuildConfigWrap
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.lists.differ.update
+import eu.darken.cap.common.lists.setupDefaults
+import eu.darken.cap.common.smart.Smart2Fragment
+import eu.darken.cap.common.viewbinding.viewBinding
+import eu.darken.cap.databinding.MainFragmentBinding
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class MainFragment : Smart2Fragment(R.layout.main_fragment) {
+
+ override val vm: MainFragmentVM by viewModels()
+ override val ui: MainFragmentBinding by viewBinding()
+
+ @Inject
+ lateinit var adapter: MainAdapter
+
+ lateinit var permissionLauncher: ActivityResultLauncher
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ log { "Request for $id was granted=$granted" }
+ vm.onPermissionResult(granted)
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+
+ ui.apply {
+ list.setupDefaults(adapter)
+ toolbar.subtitle =
+ "v${BuildConfigWrap.VERSION_NAME} (${BuildConfigWrap.VERSION_CODE}) [${BuildConfigWrap.GIT_SHA}]"
+ }
+
+ vm.listItems.observe2(ui) {
+ adapter.update(it)
+ }
+
+ vm.requestPermissionevent.observe2(ui) {
+ permissionLauncher.launch(it.permissionId)
+ }
+ super.onViewCreated(view, savedInstanceState)
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/main/ui/MainFragmentVM.kt b/app/src/main/java/eu/darken/cap/main/ui/MainFragmentVM.kt
new file mode 100644
index 00000000..f3d7fcd6
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/main/ui/MainFragmentVM.kt
@@ -0,0 +1,68 @@
+package eu.darken.cap.main.ui
+
+import android.content.Context
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.SavedStateHandle
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import eu.darken.cap.common.coroutine.DispatcherProvider
+import eu.darken.cap.common.hasApiLevel
+import eu.darken.cap.common.livedata.SingleLiveEvent
+import eu.darken.cap.common.permissions.Permission
+import eu.darken.cap.common.permissions.isGranted
+import eu.darken.cap.common.smart.Smart2VM
+import eu.darken.cap.main.ui.cards.PermissionCardVH
+import eu.darken.cap.main.ui.cards.ToggleCardVH
+import kotlinx.coroutines.flow.*
+import java.util.*
+import javax.inject.Inject
+
+@HiltViewModel
+class MainFragmentVM @Inject constructor(
+ handle: SavedStateHandle,
+ @ApplicationContext private val context: Context,
+ dispatcherProvider: DispatcherProvider,
+) : Smart2VM(dispatcherProvider = dispatcherProvider) {
+
+
+ private val enabledState: Flow = flow {
+ emit(false)
+ }
+
+ private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
+ private val requiredPermissions: Flow> = permissionCheckTrigger.map {
+ Permission.values()
+ .filter { hasApiLevel(it.minApiLevel) && !it.isGranted(context) }
+ }
+
+ val requestPermissionevent = SingleLiveEvent()
+
+ val listItems: LiveData> = combine(
+ enabledState,
+ requiredPermissions
+ ) { state, permissions ->
+ val items = mutableListOf()
+ ToggleCardVH.Item(
+ isEnabled = state,
+ onToggle = {
+
+ }
+ ).run { items.add(this) }
+
+ permissions
+ .map {
+ PermissionCardVH.Item(
+ permission = it,
+ onRequest = { requestPermissionevent.postValue(it) }
+ )
+ }
+ .forEach { items.add(it) }
+
+ items
+ }.asLiveData2()
+
+ fun onPermissionResult(granted: Boolean) {
+ if (granted) permissionCheckTrigger.value = UUID.randomUUID()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/main/ui/cards/PermissionCardVH.kt b/app/src/main/java/eu/darken/cap/main/ui/cards/PermissionCardVH.kt
new file mode 100644
index 00000000..3c6bc9d2
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/main/ui/cards/PermissionCardVH.kt
@@ -0,0 +1,34 @@
+package eu.darken.cap.main.ui.cards
+
+import android.view.ViewGroup
+import eu.darken.cap.R
+import eu.darken.cap.common.permissions.Permission
+import eu.darken.cap.databinding.MainPermissionItemBinding
+import eu.darken.cap.main.ui.MainAdapter
+
+class PermissionCardVH(parent: ViewGroup) :
+ MainAdapter.BaseVH(
+ R.layout.main_permission_item,
+ parent
+ ) {
+
+ override val viewBinding = lazy {
+ MainPermissionItemBinding.bind(itemView)
+ }
+
+ override val onBindData: MainPermissionItemBinding.(
+ item: Item,
+ payloads: List
+ ) -> Unit = { item, _ ->
+ permissionLabel.setText(item.permission.labelRes)
+ permissionDescription.setText(item.permission.descriptionRes)
+ grantAction.setOnClickListener { item.onRequest(item.permission) }
+ }
+
+ data class Item(
+ val permission: Permission,
+ val onRequest: (Permission) -> Unit
+ ) : MainAdapter.Item {
+ override val stableId: Long = this.javaClass.hashCode().toLong()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/main/ui/cards/ToggleCardVH.kt b/app/src/main/java/eu/darken/cap/main/ui/cards/ToggleCardVH.kt
new file mode 100644
index 00000000..66113277
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/main/ui/cards/ToggleCardVH.kt
@@ -0,0 +1,35 @@
+package eu.darken.cap.main.ui.cards
+
+import android.view.ViewGroup
+import eu.darken.cap.R
+import eu.darken.cap.databinding.MainToggleItemBinding
+import eu.darken.cap.main.ui.MainAdapter
+
+class ToggleCardVH(parent: ViewGroup) :
+ MainAdapter.BaseVH(
+ R.layout.main_toggle_item,
+ parent
+ ) {
+
+ override val viewBinding = lazy {
+ MainToggleItemBinding.bind(itemView)
+ }
+
+ override val onBindData: MainToggleItemBinding.(
+ item: Item,
+ payloads: List
+ ) -> Unit = { item, _ ->
+ toggleAction.text = when (item.isEnabled) {
+ true -> "Disable"
+ false -> "Enable"
+ }
+ itemView.setOnClickListener { item.onToggle() }
+ }
+
+ data class Item(
+ val isEnabled: Boolean,
+ val onToggle: () -> Unit
+ ) : MainAdapter.Item {
+ override val stableId: Long = this.javaClass.hashCode().toLong()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/monitor/core/MonitorComponent.kt b/app/src/main/java/eu/darken/cap/monitor/core/MonitorComponent.kt
new file mode 100644
index 00000000..506e9716
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/monitor/core/MonitorComponent.kt
@@ -0,0 +1,18 @@
+package eu.darken.cap.monitor.core
+
+import dagger.BindsInstance
+import dagger.hilt.DefineComponent
+import dagger.hilt.components.SingletonComponent
+
+@MonitorScope
+@DefineComponent(parent = SingletonComponent::class)
+interface MonitorComponent {
+
+ @DefineComponent.Builder
+ interface Builder {
+
+ fun coroutineScope(@BindsInstance coroutineScope: MonitorCoroutineScope): Builder
+
+ fun build(): MonitorComponent
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/monitor/core/MonitorCoroutineScope.kt b/app/src/main/java/eu/darken/cap/monitor/core/MonitorCoroutineScope.kt
new file mode 100644
index 00000000..f69445b5
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/monitor/core/MonitorCoroutineScope.kt
@@ -0,0 +1,11 @@
+package eu.darken.cap.monitor.core
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlin.coroutines.CoroutineContext
+
+class MonitorCoroutineScope : CoroutineScope {
+ override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default
+}
+
diff --git a/app/src/main/java/eu/darken/cap/monitor/core/MonitorModule.kt b/app/src/main/java/eu/darken/cap/monitor/core/MonitorModule.kt
new file mode 100644
index 00000000..282904f2
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/monitor/core/MonitorModule.kt
@@ -0,0 +1,16 @@
+package eu.darken.cap.monitor.core
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import kotlinx.coroutines.CoroutineScope
+
+@InstallIn(MonitorComponent::class)
+@Module()
+abstract class ProcessorModule {
+
+ @Binds
+ @MonitorScope
+ abstract fun processorScope(scope: MonitorCoroutineScope): CoroutineScope
+
+}
diff --git a/app/src/main/java/eu/darken/cap/monitor/core/MonitorScope.kt b/app/src/main/java/eu/darken/cap/monitor/core/MonitorScope.kt
new file mode 100644
index 00000000..f7043450
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/monitor/core/MonitorScope.kt
@@ -0,0 +1,8 @@
+package eu.darken.cap.monitor.core
+
+import javax.inject.Qualifier
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class MonitorScope
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/monitor/core/PodMonitor.kt b/app/src/main/java/eu/darken/cap/monitor/core/PodMonitor.kt
new file mode 100644
index 00000000..286c38e6
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/monitor/core/PodMonitor.kt
@@ -0,0 +1,30 @@
+package eu.darken.cap.monitor.core
+
+import eu.darken.cap.common.bluetooth.BleScanner
+import eu.darken.cap.common.debug.logging.logTag
+import eu.darken.cap.pods.core.PodDevice
+import eu.darken.cap.pods.core.PodFactory
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class PodMonitor @Inject constructor(
+ private val bleScanner: BleScanner,
+ private val podFactory: PodFactory,
+) {
+
+ val pods: Flow> = bleScanner.scan()
+ .onStart { emptyList() }
+ .map { scanResults ->
+ scanResults.mapNotNull { scanResult ->
+ podFactory.createPod(scanResult)
+ }
+ }
+
+ companion object {
+ private val TAG = logTag("Monitor", "PodMonitor")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/monitor/core/receiver/BluetoothEventReceiver.kt b/app/src/main/java/eu/darken/cap/monitor/core/receiver/BluetoothEventReceiver.kt
new file mode 100644
index 00000000..8762eb34
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/monitor/core/receiver/BluetoothEventReceiver.kt
@@ -0,0 +1,67 @@
+package eu.darken.cap.monitor.core.receiver
+
+import android.bluetooth.BluetoothA2dp
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothHeadset
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import dagger.hilt.android.AndroidEntryPoint
+import eu.darken.cap.common.bluetooth.hasFeature
+import eu.darken.cap.common.coroutine.AppScope
+import eu.darken.cap.common.debug.logging.Logging.Priority.WARN
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+import eu.darken.cap.monitor.core.worker.MonitorControl
+import eu.darken.cap.pods.core.airpods.ContinuityProtocol
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class BluetoothEventReceiver : BroadcastReceiver() {
+
+ @Inject lateinit var monitorControl: MonitorControl
+ @Inject @AppScope lateinit var appScope: CoroutineScope
+
+ override fun onReceive(context: Context, intent: Intent) {
+ log { "onReceive($context, $intent)" }
+ if (!EXPECTED_ACTIONS.contains(intent.action)) {
+ log(WARN) { "Unknown action: $intent.action" }
+ return
+ }
+
+ val bluetoothDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
+ if (bluetoothDevice == null) {
+ log(TAG, WARN) { "Event without Bluetooth device association." }
+ return
+ } else {
+ log { "Event related to $bluetoothDevice" }
+ }
+ val supportedFeatures = ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
+
+ if (supportedFeatures.isEmpty()) {
+ log { "Device has no features we support." }
+ return
+ } else {
+ log { "Device has the following we features we support $supportedFeatures" }
+ }
+
+ val pending = goAsync()
+ appScope.launch {
+ log { "Starting monitor" }
+ monitorControl.startMonitor(bluetoothDevice, forceStart = false)
+ pending.finish()
+ }
+ }
+
+ companion object {
+ private val TAG = logTag("Monitor", "EventReceiver")
+ private val EXPECTED_ACTIONS = setOf(
+ BluetoothDevice.ACTION_ACL_CONNECTED,
+ BluetoothDevice.ACTION_ACL_DISCONNECTED,
+ BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED,
+ BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED
+ )
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/monitor/core/worker/MonitorControl.kt b/app/src/main/java/eu/darken/cap/monitor/core/worker/MonitorControl.kt
new file mode 100644
index 00000000..12fc13a4
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/monitor/core/worker/MonitorControl.kt
@@ -0,0 +1,51 @@
+package eu.darken.cap.monitor.core.worker
+
+import android.bluetooth.BluetoothDevice
+import androidx.work.Data
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import eu.darken.cap.common.BuildConfigWrap
+import eu.darken.cap.common.coroutine.DispatcherProvider
+import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MonitorControl @Inject constructor(
+ private val workerManager: WorkManager,
+ private val dispatcherProvider: DispatcherProvider,
+) {
+
+ suspend fun startMonitor(
+ bluetoothDevice: BluetoothDevice? = null,
+ forceStart: Boolean
+ ): Unit = withContext(dispatcherProvider.IO) {
+ val workerData = Data.Builder().apply {
+
+ }.build()
+ log(TAG, VERBOSE) { "Worker data: $workerData" }
+
+ val workRequest = OneTimeWorkRequestBuilder