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().apply { + setInputData(workerData) + }.build() + + log(TAG, VERBOSE) { "Worker request: $workRequest" } + + val operation = workerManager.enqueueUniqueWork( + "${BuildConfigWrap.APPLICATION_ID}.monitor.worker", + if (forceStart) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP, + workRequest, + ) + + operation.result.get() + log(TAG) { "Monitor start request send." } + } + + companion object { + private val TAG = logTag("Monitor", "Control") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/cap/monitor/core/worker/MonitorWorker.kt b/app/src/main/java/eu/darken/cap/monitor/core/worker/MonitorWorker.kt new file mode 100644 index 00000000..52fb0cd2 --- /dev/null +++ b/app/src/main/java/eu/darken/cap/monitor/core/worker/MonitorWorker.kt @@ -0,0 +1,121 @@ +package eu.darken.cap.monitor.core.worker + +import android.app.NotificationManager +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import dagger.hilt.EntryPoints +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.debug.logging.logTag +import eu.darken.cap.common.flow.setupCommonEventHandlers +import eu.darken.cap.monitor.core.MonitorComponent +import eu.darken.cap.monitor.core.MonitorCoroutineScope +import eu.darken.cap.monitor.ui.MonitorNotifications +import eu.darken.cap.pods.core.airpods.ContinuityProtocol +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* + + +@HiltWorker +class MonitorWorker @AssistedInject constructor( + @Assisted private val context: Context, + @Assisted private val params: WorkerParameters, + monitorComponentBuilder: MonitorComponent.Builder, + private val monitorNotifications: MonitorNotifications, + private val notificationManager: NotificationManager, +) : CoroutineWorker(context, params) { + + private val workerScope = MonitorCoroutineScope() + private val monitorComponent = monitorComponentBuilder + .coroutineScope(workerScope) + .build() + + private val entryPoint by lazy { + EntryPoints.get(monitorComponent, MonitorWorkerEntryPoint::class.java) + } + + private val podMonitor by lazy { + entryPoint.podMonitor() + } + private val bluetoothManager2 by lazy { + entryPoint.bluetoothManager2() + } + private var finishedWithError = false + + init { + log(TAG, VERBOSE) { "init(): workerId=$id" } + } + + override suspend fun doWork(): Result = try { + val start = System.currentTimeMillis() + log(TAG, VERBOSE) { "Executing $inputData now (runAttemptCount=$runAttemptCount)" } + + bluetoothManager2 + .isBluetoothEnabled + .flatMapLatest { bluetoothManager2.connectedDevices() } + .map { devices -> + devices.any { device -> + ContinuityProtocol.BLE_FEATURE_UUIDS.any { feature -> + device.hasFeature(feature) + } + } + } + .setupCommonEventHandlers(TAG) { "ConnectedDevices" } + .flatMapLatest { arePodsConnected -> + flow { + if (arePodsConnected) { + log(TAG) { "Pods are connected, aborting any timeout." } + } else { + log(TAG) { "No Pods are connected, canceling worker soon." } + delay(60 * 1000) + log(TAG) { "Canceling worker now, still no Pods connected." } + // FIXME +// workerScope.coroutineContext.cancelChildren() + } + } + } + .launchIn(workerScope) + + + val monitorJob = podMonitor.pods + .setupCommonEventHandlers(TAG) { "PodMonitor" } + .onStart { + setForeground(monitorNotifications.getForegroundInfo(null)) + } + .onEach { + notificationManager.notify( + MonitorNotifications.NOTIFICATION_ID, + monitorNotifications.getNotification(it.firstOrNull()) + ) + } + .launchIn(workerScope) + + log(TAG, VERBOSE) { "monitor job is active" } + monitorJob.join() + log(TAG, VERBOSE) { "monitor job quit" } + + val duration = System.currentTimeMillis() - start + + log(TAG, VERBOSE) { "Execution finished after ${duration}ms, $inputData" } + + Result.success(inputData) + } catch (e: Throwable) { + log(TAG, ERROR) { "Execution failed:\n${e.asLog()}" } + finishedWithError = true + // TODO update result? + Result.failure(inputData) + } finally { + this.workerScope.cancel("Worker finished (withError?=$finishedWithError).") + } + + companion object { + val TAG = logTag("Monitor", "Worker") + } +} diff --git a/app/src/main/java/eu/darken/cap/monitor/core/worker/MonitorWorkerEntryPoint.kt b/app/src/main/java/eu/darken/cap/monitor/core/worker/MonitorWorkerEntryPoint.kt new file mode 100644 index 00000000..1343e89c --- /dev/null +++ b/app/src/main/java/eu/darken/cap/monitor/core/worker/MonitorWorkerEntryPoint.kt @@ -0,0 +1,14 @@ +package eu.darken.cap.monitor.core.worker + +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import eu.darken.cap.common.bluetooth.BluetoothManager2 +import eu.darken.cap.monitor.core.MonitorComponent +import eu.darken.cap.monitor.core.PodMonitor + +@InstallIn(MonitorComponent::class) +@EntryPoint +interface MonitorWorkerEntryPoint { + fun podMonitor(): PodMonitor + fun bluetoothManager2(): BluetoothManager2 +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/cap/monitor/ui/MonitorNotifications.kt b/app/src/main/java/eu/darken/cap/monitor/ui/MonitorNotifications.kt new file mode 100644 index 00000000..957b6365 --- /dev/null +++ b/app/src/main/java/eu/darken/cap/monitor/ui/MonitorNotifications.kt @@ -0,0 +1,92 @@ +package eu.darken.cap.monitor.ui + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.ForegroundInfo +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.cap.R +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.hasApiLevel +import eu.darken.cap.main.ui.MainActivity +import eu.darken.cap.pods.core.PodDevice +import javax.inject.Inject + + +class MonitorNotifications @Inject constructor( + @ApplicationContext private val context: Context, + notificationManager: NotificationManager, +) { + + private val builder: NotificationCompat.Builder + + init { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + context.getString(R.string.notification_channel_device_status_label), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) + } + + val openIntent = Intent(context, MainActivity::class.java) + val openPi = if (hasApiLevel(31)) { + PendingIntent.getActivity(context, 0, openIntent, PendingIntent.FLAG_IMMUTABLE) + } else { + PendingIntent.getActivity(context, 0, openIntent, 0) + } + + builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setChannelId(NOTIFICATION_CHANNEL_ID) + .setContentIntent(openPi) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setSmallIcon(R.drawable.ic_notification_device_status_icon) + .setContentTitle(context.getString(R.string.app_name)) + .setStyle( + NotificationCompat.BigTextStyle().bigText(context.getString(R.string.device_status_loading_message)) + ) + } + + fun getBuilder(podDevice: PodDevice?): NotificationCompat.Builder { + if (podDevice == null) return builder + +// builder.setContentTitle(gear.primary.get(context)) +// builder.setStyle(NotificationCompat.BigTextStyle().bigText(it.secondary.get(context))) + log(TAG, VERBOSE) { "updatingNotification(): $podDevice" } + return builder + } + + fun getNotification(podDevice: PodDevice?): Notification = getBuilder(podDevice).build() + + fun getForegroundInfo(podDevice: PodDevice?): ForegroundInfo = getBuilder(podDevice).toForegroundInfo() + + @SuppressLint("InlinedApi") + private fun NotificationCompat.Builder.toForegroundInfo(): ForegroundInfo = if (hasApiLevel(29)) { + ForegroundInfo( + NOTIFICATION_ID, + this.build(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + ) + } else { + ForegroundInfo( + NOTIFICATION_ID, + this.build() + ) + } + + companion object { + val TAG = logTag("Monitor", "Notifications") + private const val NOTIFICATION_CHANNEL_ID = "eu.darken.cap.notification.channel.device.status" + internal const val NOTIFICATION_ID = 1 + } +} diff --git a/app/src/main/java/eu/darken/cap/pods/core/PodDevice.kt b/app/src/main/java/eu/darken/cap/pods/core/PodDevice.kt new file mode 100644 index 00000000..930b16ff --- /dev/null +++ b/app/src/main/java/eu/darken/cap/pods/core/PodDevice.kt @@ -0,0 +1,12 @@ +package eu.darken.cap.pods.core + +import android.bluetooth.le.ScanResult + +interface PodDevice { + + val scanResult: ScanResult + + interface Status { + + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/cap/pods/core/PodFactory.kt b/app/src/main/java/eu/darken/cap/pods/core/PodFactory.kt new file mode 100644 index 00000000..3f4914a4 --- /dev/null +++ b/app/src/main/java/eu/darken/cap/pods/core/PodFactory.kt @@ -0,0 +1,36 @@ +package eu.darken.cap.pods.core + +import android.bluetooth.le.ScanResult +import dagger.Reusable +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 +import eu.darken.cap.pods.core.airpods.AirPodsFactory +import javax.inject.Inject + +@Reusable +class PodFactory @Inject constructor( + private val airPodsFactory: AirPodsFactory +) { + + suspend fun createPod(scanResult: ScanResult): PodDevice? { + log(TAG, VERBOSE) { "Trying to create Pod for $scanResult" } + + val pod = airPodsFactory.create(scanResult) + if (pod != null) { + log(TAG) { "Pod created: $pod" } + return pod + } else { + log(TAG, WARN) { "Not an AirPod : $scanResult" } + } + + + log(TAG, WARN) { "Failed to find matching PodFactory for $scanResult" } + return null + } + + companion object { + private val TAG = logTag("Pod", "Factory") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/cap/pods/core/airpods/AirPodsDevice.kt b/app/src/main/java/eu/darken/cap/pods/core/airpods/AirPodsDevice.kt new file mode 100644 index 00000000..4dde577b --- /dev/null +++ b/app/src/main/java/eu/darken/cap/pods/core/airpods/AirPodsDevice.kt @@ -0,0 +1,191 @@ +package eu.darken.cap.pods.core.airpods + +import eu.darken.cap.common.debug.logging.log +import eu.darken.cap.common.isBitSet +import eu.darken.cap.common.lowerNibble +import eu.darken.cap.common.upperNibble + +interface AirPodsDevice : ApplePods { + + val tag: String + + // We start counting at the airpods prefix byte + val rawPrefix: UByte + get() = proximityMessage.data[0] + + val rawDeviceModel: UShort + get() = (((proximityMessage.data[1].toInt() and 255) shl 8) or (proximityMessage.data[2].toInt() and 255)).toUShort() + + val rawStatus: UByte + get() = proximityMessage.data[3] + + val rawPodsBattery: UByte + get() = proximityMessage.data[4] + + val rawCaseBattery: UByte + get() = proximityMessage.data[5] + + val rawCaseLidState: UByte + get() = proximityMessage.data[6] + + val rawDeviceColor: UByte + get() = proximityMessage.data[7] + + val rawSuffix: UByte + get() = proximityMessage.data[8] + + val microPhonePod: Pod + get() = when (rawStatus.isBitSet(5)) { + true -> Pod.LEFT + false -> Pod.RIGHT + } + + enum class Pod { + LEFT, + RIGHT + } + + val batteryLeftPodPercent: Float? + get() { + val value = when (microPhonePod) { + Pod.LEFT -> rawPodsBattery.lowerNibble.toInt() + Pod.RIGHT -> rawPodsBattery.upperNibble.toInt() + } + return when (value) { + 15 -> null + else -> if (value >= 10) { + log(tag) { "Left pod: Above 100% battery: $value" } + 1.0f + } else { + (value / 10f) + } + } + } + + val batteryRightPodPercent: Float? + get() { + val value = when (microPhonePod) { + Pod.LEFT -> rawPodsBattery.upperNibble.toInt() + Pod.RIGHT -> rawPodsBattery.lowerNibble.toInt() + } + return when (value) { + 15 -> null + else -> if (value > 10) { + log(tag) { "Right pod: Above 100% battery: $value" } + 1.0f + } else { + value / 10f + } + } + } + + val batteryCasePercent: Float? + get() = when (val value = rawCaseBattery.lowerNibble.toInt()) { + 15 -> null + else -> if (value > 10) { + log(tag) { "Case: Above 100% battery: $value" } + 1.0f + } else { + value / 10f + } + } + + val isLeftPodInEar: Boolean + get() = when (microPhonePod) { + Pod.LEFT -> rawStatus.isBitSet(1) + Pod.RIGHT -> rawStatus.isBitSet(3) + } + + val isRightPodInEar: Boolean + get() = when (microPhonePod) { + Pod.LEFT -> rawStatus.isBitSet(3) + Pod.RIGHT -> rawStatus.isBitSet(1) + } + + val status: Status + get() = Status.values().firstOrNull { it.raw == rawStatus } ?: Status.UNKNOWN + + enum class Status(val raw: UByte?) { + BOTH_AIRPODS_IN_CASE(0x55), + LEFT_IN_EAR(0x33), + AIRPLANE(0x02), + UNKNOWN(null); + + constructor(raw: Int) : this(raw.toUByte()) + } + + // 1010101 0x55 85 Both In Case + // 0101011 0x2b 43 Both In Ear + // 0101001 0x29 41 Right in Ear + // 0100011 0x23 35 Left In Ear + // 0100001 0x21 33 Neither in Ear or In Case + // 1110001 0x71 113 Left in Case, Right on Desk + // 0010001 0x11 17 Left in Case, Right on Desk + // 0010011 0x13 19 Left in Case, Right in Ear + // 1110011 0x73 115 Left in Case, Right in Ear + + + val isCaseCharging: Boolean + get() = rawCaseBattery.upperNibble.isBitSet(2) + + val isLeftPodCharging: Boolean + get() = when (microPhonePod) { + Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(0) + Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(1) + } + + val isRightPodCharging: Boolean + get() = when (microPhonePod) { + Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(1) + Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(0) + } + + val caseLidState: LidState + get() = LidState.values().firstOrNull { it.raw == rawCaseLidState } ?: LidState.UNKNOWN + + enum class LidState(val raw: UByte?) { + OPEN(0x31), + CLOSED(0x38), + NOT_IN_CASE(0x01), + UNKNOWN(null); + + constructor(raw: Int) : this(raw.toUByte()) + } + + val deviceColor: DeviceColor + get() = DeviceColor.values().firstOrNull { it.raw == rawDeviceColor } ?: DeviceColor.UNKNOWN + + enum class DeviceColor(val raw: UByte?) { + WHITE(0x00), + BLACK(0x01), + RED(0x02), + BLUE(0x03), + PINK(0x04), + GRAY(0x05), + SILVER(0x06), + GOLD(0x07), + ROSE_GOLD(0x08), + SPACE_GRAY(0x09), + DARK_BLUE(0x0a), + LIGHT_BLUE(0x0b), + YELLOW(0x0c), + UNKNOWN(null); + + constructor(raw: Int) : this(raw.toUByte()) + } + + val connectionState: ConnectionState + get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN + + enum class ConnectionState(val raw: UByte?) { + DISCONNECTED(0x00), + IDLE(0x04), + MUSIC(0x05), + CALL(0x06), + RINGING(0x07), + HANGING_UP(0x09), + UNKNOWN(null); + + constructor(raw: Int) : this(raw.toUByte()) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/cap/pods/core/airpods/AirPodsFactory.kt b/app/src/main/java/eu/darken/cap/pods/core/airpods/AirPodsFactory.kt new file mode 100644 index 00000000..1120f962 --- /dev/null +++ b/app/src/main/java/eu/darken/cap/pods/core/airpods/AirPodsFactory.kt @@ -0,0 +1,110 @@ +package eu.darken.cap.pods.core.airpods + +import android.bluetooth.le.ScanResult +import dagger.Reusable +import eu.darken.cap.bugreporting.Bugs +import eu.darken.cap.common.debug.logging.Logging.Priority.INFO +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.debug.logging.logTag +import eu.darken.cap.pods.core.PodDevice +import eu.darken.cap.pods.core.airpods.models.AirPodsGen1 +import eu.darken.cap.pods.core.airpods.models.AirPodsGen2 +import eu.darken.cap.pods.core.airpods.models.AirPodsPro +import eu.darken.cap.pods.core.airpods.models.UnknownAppleDevice +import javax.inject.Inject + +@Reusable +class AirPodsFactory @Inject constructor( + private val continuityProtocolDecoder: ContinuityProtocol.Decoder, + private val proximityPairingDecoder: ProximityPairing.Decoder +) { + + data class Message( + // 0x07 ; AirPods message1 byte + // 25; Length 1byte + // 0x01 + val type: UByte, + val deviceModel: UShort, + val status: UByte, + val batteryIndicator: UShort, + val lidCount: UByte, + val deviceColor: UByte, + val encryptedData: UShort, + ) + + suspend fun create(scanResult: ScanResult): PodDevice? { + val messages = try { + continuityProtocolDecoder.decode(scanResult) + } catch (e: Exception) { + log(TAG, WARN) { "Data wasn't continuity protocol conform:\n${e.asLog()}" } + return null + } + if (messages.isEmpty()) { + log(TAG, WARN) { "Data contained no continuity messages: $scanResult" } + return null + } + + if (messages.size > 1) { + log(TAG, WARN) { "Decoded multiple continuity messages, picking first: $messages" } + } + + val proximityMessage = proximityPairingDecoder.decode(messages.first()) + if (proximityMessage == null) { + log(TAG) { "Not a proximity pairing message: $messages" } + return null + } + + log(TAG, INFO) { + val data = scanResult.scanRecord!!.getManufacturerSpecificData( + ContinuityProtocol.APPLE_COMPANY_IDENTIFIER + )!! + val dataHex = data.joinToString(separator = " ") { + String.format("%02X", it) + } + "Decoding (MAC=${scanResult.device.address}, nanos=${scanResult.timestampNanos}, rssi=${scanResult.rssi}): $dataHex" + } + + val dm = ( + ((proximityMessage.data[1].toInt() and 255) shl 8) or (proximityMessage.data[2].toInt() and 255) + ).toUShort() + val dmDirty = proximityMessage.data[1] + + return when { + dm == 0x0220.toUShort() || dmDirty == 2.toUByte() -> AirPodsGen1(scanResult, proximityMessage) + dm == 0x0F20.toUShort() || dmDirty == 15.toUByte() -> AirPodsGen2(scanResult, proximityMessage) + dm == 0x0e20.toUShort() || dmDirty == 14.toUByte() -> AirPodsPro(scanResult, proximityMessage) +// dmDirty == 10.toUByte() -> { +// TODO("Airpods Max") +// } +// dmDirty == 11.toUByte() -> { +// TODO("PowerBeatsPro") +// } +// dm == 0x0520.toUShort() || dmDirty == 5.toUByte() -> { +// TODO("BeatsX") +// } +// dmDirty == 0.toUByte() -> { +// TODO("BeatsFlex") +// } +// dm == 0x0620.toUShort() || dmDirty == 6.toUByte() -> { +// TODO("Beats Solo 3") +// } +// dmDirty == 9.toUByte() -> { +// TODO("Beats Studio 3") +// } +// dm == 0x0320.toUShort() || dmDirty == 3.toUByte() -> { +// TODO("Power Beats 3") +// } + else -> { + log(TAG, WARN) { "Unknown proximity message type" } + Bugs.report(IllegalArgumentException("Unknown ProximityMessage: $proximityMessage")) + UnknownAppleDevice(scanResult, proximityMessage) + } + } + } + + companion object { + private val TAG = logTag("Pod", "AirPods", "Factory") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/cap/pods/core/airpods/ApplePods.kt b/app/src/main/java/eu/darken/cap/pods/core/airpods/ApplePods.kt new file mode 100644 index 00000000..735eb69d --- /dev/null +++ b/app/src/main/java/eu/darken/cap/pods/core/airpods/ApplePods.kt @@ -0,0 +1,14 @@ +package eu.darken.cap.pods.core.airpods + +import eu.darken.cap.common.debug.logging.logTag +import eu.darken.cap.pods.core.PodDevice + +interface ApplePods : PodDevice { + + val proximityMessage: ProximityPairing.Message + + companion object { + + val TAG = logTag("Pod", "BaseAirPods") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/cap/pods/core/airpods/ContinuityProtocol.kt b/app/src/main/java/eu/darken/cap/pods/core/airpods/ContinuityProtocol.kt new file mode 100644 index 00000000..d20c42f9 --- /dev/null +++ b/app/src/main/java/eu/darken/cap/pods/core/airpods/ContinuityProtocol.kt @@ -0,0 +1,66 @@ +package eu.darken.cap.pods.core.airpods + +import android.bluetooth.le.ScanResult +import android.os.ParcelUuid +import dagger.Reusable +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 javax.inject.Inject + +object ContinuityProtocol { + + data class Message( + val type: UByte, + val length: Int, + val data: UByteArray + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Message) return false + + if (!data.contentEquals(other.data)) return false + + return true + } + + override fun hashCode(): Int = data.contentHashCode() + } + + @Reusable + class Decoder @Inject constructor() { + fun decode(scanResult: ScanResult): List = scanResult.scanRecord + ?.getManufacturerSpecificData(APPLE_COMPANY_IDENTIFIER) + ?.let { data -> + val messages = mutableListOf() + + var remainingData = data.asUByteArray() + while (remainingData.size >= 2) { + val dataLength = remainingData[1].toInt() + val dataStart = 2 + val dataEnd = dataStart + dataLength + Message( + type = remainingData[0], + length = dataLength, + data = remainingData.copyOfRange(dataStart, dataEnd) + ).run { messages.add(this) } + + remainingData = remainingData.copyOfRange(dataEnd, remainingData.size) + } + if (remainingData.isNotEmpty()) { + log(TAG, WARN) { "Data contained malformed protocol message $remainingData" } + } + messages.toList() + } ?: emptyList() + } + + const val APPLE_COMPANY_IDENTIFIER = 0x004C + + // Continuity protocol data is in these vendor specific data sets + val BLE_FEATURE_UUIDS = setOf( + ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"), + ParcelUuid.fromString("2a72e02b-7b99-778f-014d-ad0b7221ec74") + ) + + val TAG = logTag("ContinuityProtocol", "Decoder") +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/cap/pods/core/airpods/ProximityPairing.kt b/app/src/main/java/eu/darken/cap/pods/core/airpods/ProximityPairing.kt new file mode 100644 index 00000000..68645427 --- /dev/null +++ b/app/src/main/java/eu/darken/cap/pods/core/airpods/ProximityPairing.kt @@ -0,0 +1,60 @@ +package eu.darken.cap.pods.core.airpods + +import android.bluetooth.le.ScanFilter +import dagger.Reusable +import eu.darken.cap.common.debug.logging.log +import javax.inject.Inject + + +object ProximityPairing { + + data class Message( + val type: UByte, + val length: Int, + val data: UByteArray + ) + + @Reusable + class Decoder @Inject constructor() { + fun decode(message: ContinuityProtocol.Message): Message? { + if (message.type != CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING) { + log(ApplePods.TAG) { "Not a proximity pairing message: $this" } + return null + } + if (message.length != PROXIMITY_PAIRING_MESSAGE_LENGTH) { + log(ApplePods.TAG) { "Proximity pairing message has invalid length." } + return null + } + + return Message( + type = message.type, + length = message.length, + data = message.data + ) + } + } + + fun getBleScanFilter(): Set { + val manufacturerData = ByteArray(CONTINUITY_PROTOCOL_MESSAGE_LENGTH).apply { + this[0] = CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING.toByte() + this[1] = PROXIMITY_PAIRING_MESSAGE_LENGTH.toByte() + } + + val manufacturerDataMask = ByteArray(CONTINUITY_PROTOCOL_MESSAGE_LENGTH).apply { + this[0] = 1 + this[1] = 1 + } + val builder = ScanFilter.Builder().apply { + setManufacturerData( + ContinuityProtocol.APPLE_COMPANY_IDENTIFIER, + manufacturerData, + manufacturerDataMask + ) + } + return setOf(builder.build()) + } + + private const val CONTINUITY_PROTOCOL_MESSAGE_LENGTH = 27 + private val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte() + private const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25 +} diff --git a/app/src/main/java/eu/darken/cap/pods/core/airpods/models/AirPodsGen1.kt b/app/src/main/java/eu/darken/cap/pods/core/airpods/models/AirPodsGen1.kt new file mode 100644 index 00000000..559c641a --- /dev/null +++ b/app/src/main/java/eu/darken/cap/pods/core/airpods/models/AirPodsGen1.kt @@ -0,0 +1,13 @@ +package eu.darken.cap.pods.core.airpods.models + +import android.bluetooth.le.ScanResult +import eu.darken.cap.common.debug.logging.logTag +import eu.darken.cap.pods.core.airpods.AirPodsDevice +import eu.darken.cap.pods.core.airpods.ProximityPairing + +data class AirPodsGen1 constructor( + override val scanResult: ScanResult, + override val proximityMessage: ProximityPairing.Message +) : AirPodsDevice { + override val tag: String = logTag("Pod", "AirPodsGen1") +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/cap/pods/core/airpods/models/AirPodsGen2.kt b/app/src/main/java/eu/darken/cap/pods/core/airpods/models/AirPodsGen2.kt new file mode 100644 index 00000000..1437b770 --- /dev/null +++ b/app/src/main/java/eu/darken/cap/pods/core/airpods/models/AirPodsGen2.kt @@ -0,0 +1,13 @@ +package eu.darken.cap.pods.core.airpods.models + +import android.bluetooth.le.ScanResult +import eu.darken.cap.common.debug.logging.logTag +import eu.darken.cap.pods.core.airpods.AirPodsDevice +import eu.darken.cap.pods.core.airpods.ProximityPairing + +data class AirPodsGen2 constructor( + override val scanResult: ScanResult, + override val proximityMessage: ProximityPairing.Message +) : AirPodsDevice { + override val tag: String = logTag("Pod", "AirPodsGen2") +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/cap/pods/core/airpods/models/AirPodsMax.kt b/app/src/main/java/eu/darken/cap/pods/core/airpods/models/AirPodsMax.kt new file mode 100644 index 00000000..d5acd2eb --- /dev/null +++ b/app/src/main/java/eu/darken/cap/pods/core/airpods/models/AirPodsMax.kt @@ -0,0 +1,10 @@ +package eu.darken.cap.pods.core.airpods.models + +import android.bluetooth.le.ScanResult +import eu.darken.cap.pods.core.airpods.ApplePods +import eu.darken.cap.pods.core.airpods.ProximityPairing + +data class AirPodsMax constructor( + override val scanResult: ScanResult, + override val proximityMessage: ProximityPairing.Message +) : ApplePods \ No newline at end of file diff --git a/app/src/main/java/eu/darken/cap/pods/core/airpods/models/AirPodsPro.kt b/app/src/main/java/eu/darken/cap/pods/core/airpods/models/AirPodsPro.kt new file mode 100644 index 00000000..040555f6 --- /dev/null +++ b/app/src/main/java/eu/darken/cap/pods/core/airpods/models/AirPodsPro.kt @@ -0,0 +1,14 @@ +package eu.darken.cap.pods.core.airpods.models + +import android.bluetooth.le.ScanResult +import eu.darken.cap.common.debug.logging.logTag +import eu.darken.cap.pods.core.airpods.AirPodsDevice +import eu.darken.cap.pods.core.airpods.ProximityPairing + +data class AirPodsPro constructor( + override val scanResult: ScanResult, + override val proximityMessage: ProximityPairing.Message +) : AirPodsDevice { + + override val tag: String = logTag("Pod", "AirPodsPro") +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/cap/pods/core/airpods/models/UnknownAppleDevice.kt b/app/src/main/java/eu/darken/cap/pods/core/airpods/models/UnknownAppleDevice.kt new file mode 100644 index 00000000..84c0ccbc --- /dev/null +++ b/app/src/main/java/eu/darken/cap/pods/core/airpods/models/UnknownAppleDevice.kt @@ -0,0 +1,10 @@ +package eu.darken.cap.pods.core.airpods.models + +import android.bluetooth.le.ScanResult +import eu.darken.cap.pods.core.airpods.ApplePods +import eu.darken.cap.pods.core.airpods.ProximityPairing + +data class UnknownAppleDevice constructor( + override val scanResult: ScanResult, + override val proximityMessage: ProximityPairing.Message +) : ApplePods \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_add_24.xml b/app/src/main/res/drawable/ic_baseline_add_24.xml new file mode 100644 index 00000000..54da215b --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_notification_device_status_icon.xml b/app/src/main/res/drawable/ic_notification_device_status_icon.xml new file mode 100644 index 00000000..adc33c38 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_device_status_icon.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/launch_screen.xml b/app/src/main/res/drawable/launch_screen.xml new file mode 100644 index 00000000..4f525576 --- /dev/null +++ b/app/src/main/res/drawable/launch_screen.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml new file mode 100644 index 00000000..24b3ac4c --- /dev/null +++ b/app/src/main/res/layout/main_activity.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/main_fragment.xml b/app/src/main/res/layout/main_fragment.xml new file mode 100644 index 00000000..ad74c387 --- /dev/null +++ b/app/src/main/res/layout/main_fragment.xml @@ -0,0 +1,37 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/main_permission_item.xml b/app/src/main/res/layout/main_permission_item.xml new file mode 100644 index 00000000..7e22f5d8 --- /dev/null +++ b/app/src/main/res/layout/main_permission_item.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/main_toggle_item.xml b/app/src/main/res/layout/main_toggle_item.xml new file mode 100644 index 00000000..16f55e98 --- /dev/null +++ b/app/src/main/res/layout/main_toggle_item.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..eca70cfe --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..eca70cfe --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..a571e600 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..61da551c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..c41dd285 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..db5080a7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..6dba46da Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..da31a871 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..15ac6817 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..b216f2d3 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..f25a4197 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..e96783cc Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml new file mode 100644 index 00000000..e29677d9 --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..d69a4b5b --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,32 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..7733ee46 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,57 @@ + + + #6750A4 + #FFFFFF + #EADDFF + #21005D + #625B71 + #FFFFFF + #E8DEF8 + #1D192B + #7D5260 + #FFFFFF + #FFD8E4 + #31111D + #B3261E + #F9DEDC + #FFFFFF + #410E0B + #FFFBFE + #1C1B1F + #FFFBFE + #1C1B1F + #E7E0EC + #49454F + #79747E + #F4EFF4 + #313033 + #D0BCFF + #D0BCFF + #381E72 + #4F378B + #EADDFF + #CCC2DC + #332D41 + #4A4458 + #E8DEF8 + #EFB8C8 + #492532 + #633B48 + #FFD8E4 + #F2B8B5 + #8C1D18 + #601410 + #F9DEDC + #1C1B1F + #E6E1E5 + #1C1B1F + #E6E1E5 + #49454F + #CAC4D0 + #938F99 + #1C1B1F + #E6E1E5 + #6750A4 + #6750A4 + #B3261E + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..d793dd89 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + Companion App for AirPods + Error + BLUETOOTH CONNECT + Required to be able to connect to paired Bluetooth devices. + Loading device status + Device status + BLUETOOTH SCAN + Required to be able to discover and pair nearby Bluetooth devices. + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..216d8781 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..53bffe0b --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,32 @@ + + + + + \ No newline at end of file diff --git a/app/src/test/java/eu/darken/cap/common/flow/DynamicStateFlowTest.kt b/app/src/test/java/eu/darken/cap/common/flow/DynamicStateFlowTest.kt new file mode 100644 index 00000000..1a54e5d2 --- /dev/null +++ b/app/src/test/java/eu/darken/cap/common/flow/DynamicStateFlowTest.kt @@ -0,0 +1,351 @@ +package eu.darken.cap.common.flow + +import eu.darken.cap.common.collections.mutate +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.instanceOf +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.TestCoroutineScope +import org.junit.jupiter.api.Test +import testhelper.BaseTest +import testhelper.coroutine.runBlockingTest2 +import testhelper.flow.test +import java.io.IOException +import java.lang.Thread.sleep +import kotlin.concurrent.thread + +class DynamicStateFlowTest : BaseTest() { + + // Without an init value, there isn't a way to keep using the flow + @Test + fun `exceptions on initialization are rethrown`() { + val testScope = TestCoroutineScope() + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + coroutineContext = Dispatchers.Unconfined, + startValueProvider = { throw IOException() } + ) + runBlocking { + withTimeoutOrNull(500) { + // This blocking scope gets the init exception as the first caller + hotData.flow.firstOrNull() + } shouldBe null + } + + testScope.advanceUntilIdle() + + testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class) + } + + @Test + fun `subscription doesn't end when no subscriber is collecting, mode Lazily`() { + val testScope = TestCoroutineScope() + val valueProvider = mockk String>() + coEvery { valueProvider.invoke(any()) } returns "Test" + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + coroutineContext = Dispatchers.Unconfined, + startValueProvider = valueProvider, + ) + + testScope.apply { + runBlockingTest2(allowUncompleted = true) { + hotData.flow.first() shouldBe "Test" + hotData.flow.first() shouldBe "Test" + } + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } + } + + @Test + fun `value updates`() { + val testScope = TestCoroutineScope() + val valueProvider = mockk Long>() + coEvery { valueProvider.invoke(any()) } returns 1 + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + startValueProvider = valueProvider, + ) + + val testCollector = hotData.flow.test(startOnScope = testScope) + testCollector.silent = true + + (1..16).forEach { _ -> + thread { + (1..200).forEach { _ -> + sleep(10) + hotData.updateAsync( + onUpdate = { this + 1L }, + onError = { throw it } + ) + } + } + } + + runBlocking { + testCollector.await { list, _ -> list.size == 3201 } + testCollector.latestValues shouldBe (1..3201).toList() + } + + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } + + data class TestData( + val number: Long = 1 + ) + + @Test + fun `check multi threading value updates with more complex data`() { + val testScope = TestCoroutineScope() + val valueProvider = mockk Map>() + coEvery { valueProvider.invoke(any()) } returns mapOf("data" to TestData()) + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + startValueProvider = valueProvider, + ) + + val testCollector = hotData.flow.test(startOnScope = testScope) + testCollector.silent = true + + (1..10).forEach { _ -> + thread { + (1..400).forEach { _ -> + hotData.updateAsync { + mutate { + this["data"] = getValue("data").copy( + number = getValue("data").number + 1 + ) + } + } + } + } + } + + runBlocking { + testCollector.await { list, _ -> list.size == 4001 } + testCollector.latestValues.map { it.values.single().number } shouldBe (1L..4001L).toList() + } + + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } + + @Test + fun `only emit new values if they actually changed updates`() { + val testScope = TestCoroutineScope() + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + startValueProvider = { "1" }, + ) + + val testCollector = hotData.flow.test(startOnScope = testScope) + testCollector.silent = true + + hotData.updateAsync { "1" } + hotData.updateAsync { "2" } + hotData.updateAsync { "2" } + hotData.updateAsync { "1" } + + runBlocking { + testCollector.await { list, _ -> list.size == 3 } + testCollector.latestValues shouldBe listOf("1", "2", "1") + } + } + + @Test + fun `multiple subscribers share the flow`() { + val testScope = TestCoroutineScope() + val valueProvider = mockk String>() + coEvery { valueProvider.invoke(any()) } returns "Test" + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + startValueProvider = valueProvider, + ) + + testScope.runBlockingTest2(allowUncompleted = true) { + val sub1 = hotData.flow.test(tag = "sub1", startOnScope = this) + val sub2 = hotData.flow.test(tag = "sub2", startOnScope = this) + val sub3 = hotData.flow.test(tag = "sub3", startOnScope = this) + + hotData.updateAsync { "A" } + hotData.updateAsync { "B" } + hotData.updateAsync { "C" } + + listOf(sub1, sub2, sub3).forEach { + it.await { list, _ -> list.size == 4 } + it.latestValues shouldBe listOf("Test", "A", "B", "C") + it.cancel() + } + + hotData.flow.first() shouldBe "C" + } + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } + + @Test + fun `value is persisted between unsubscribes`() = runBlockingTest2(allowUncompleted = true) { + val valueProvider = mockk Long>() + coEvery { valueProvider.invoke(any()) } returns 1 + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = this, + coroutineContext = this.coroutineContext, + startValueProvider = valueProvider, + ) + + val testCollector1 = hotData.flow.test(tag = "collector1", startOnScope = this) + testCollector1.silent = false + + (1..10).forEach { _ -> + hotData.updateAsync { + this + 1L + } + } + + advanceUntilIdle() + + testCollector1.await { list, _ -> list.size == 11 } + testCollector1.latestValues shouldBe (1L..11L).toList() + + testCollector1.cancel() + testCollector1.awaitFinal() + + val testCollector2 = hotData.flow.test(tag = "collector2", startOnScope = this) + testCollector2.silent = false + + advanceUntilIdle() + + testCollector2.cancel() + testCollector2.awaitFinal() + + testCollector2.latestValues shouldBe listOf(11L) + + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } + + @Test + fun `blocking update is actually blocking`() = runBlocking { + val testScope = TestCoroutineScope() + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + coroutineContext = testScope.coroutineContext, + startValueProvider = { + delay(2000) + 2 + }, + ) + + hotData.updateAsync { + delay(2000) + this + 1 + } + + val testCollector = hotData.flow.test(startOnScope = testScope) + + testScope.advanceUntilIdle() + + hotData.updateBlocking { this - 3 } shouldBe 0 + + testCollector.await { _, i -> i == 3 } + testCollector.latestValues shouldBe listOf(2, 3, 0) + + testCollector.cancel() + } + + @Test + fun `blocking update rethrows error`() = runBlocking { + val testScope = TestCoroutineScope() + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + coroutineContext = testScope.coroutineContext, + startValueProvider = { + delay(2000) + 2 + }, + ) + + val testCollector = hotData.flow.test(startOnScope = testScope) + + testScope.advanceUntilIdle() + + shouldThrow { + hotData.updateBlocking { throw IOException("Surprise") } shouldBe 0 + } + hotData.flow.first() shouldBe 2 + + hotData.updateBlocking { 3 } shouldBe 3 + hotData.flow.first() shouldBe 3 + + testScope.uncaughtExceptions.singleOrNull() shouldBe null + + testCollector.cancel() + } + + @Test + fun `async updates error handler`() { + val testScope = TestCoroutineScope() + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + startValueProvider = { 1 }, + ) + + val testCollector = hotData.flow.test(startOnScope = testScope) + testScope.advanceUntilIdle() + + hotData.updateAsync { throw IOException("Surprise") } + + testScope.advanceUntilIdle() + + testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class) + + testCollector.cancel() + } + + @Test + fun `async updates rethrow errors on HotDataFlow scope if no error handler is set`() = runBlocking { + val testScope = TestCoroutineScope() + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + startValueProvider = { 1 }, + ) + + val testCollector = hotData.flow.test(startOnScope = testScope) + testScope.advanceUntilIdle() + + var thrownError: Exception? = null + + hotData.updateAsync( + onUpdate = { throw IOException("Surprise") }, + onError = { thrownError = it } + ) + + testScope.advanceUntilIdle() + thrownError!!.shouldBeInstanceOf() + testScope.uncaughtExceptions.singleOrNull() shouldBe null + + testCollector.cancel() + } +} diff --git a/app/src/test/java/eu/darken/cap/pods/core/airpods/AirPodsFactoryTest.kt b/app/src/test/java/eu/darken/cap/pods/core/airpods/AirPodsFactoryTest.kt new file mode 100644 index 00000000..9141452e --- /dev/null +++ b/app/src/test/java/eu/darken/cap/pods/core/airpods/AirPodsFactoryTest.kt @@ -0,0 +1,290 @@ +package eu.darken.cap.pods.core.airpods + +import android.bluetooth.BluetoothDevice +import android.bluetooth.le.ScanRecord +import android.bluetooth.le.ScanResult +import eu.darken.cap.pods.core.PodDevice +import eu.darken.cap.pods.core.airpods.models.AirPodsGen1 +import eu.darken.cap.pods.core.airpods.models.AirPodsPro +import eu.darken.cap.pods.core.airpods.models.UnknownAppleDevice +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.instanceOf +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelper.BaseTest + +class AirPodsFactoryTest : BaseTest() { + + @MockK lateinit var scanResult: ScanResult + @MockK lateinit var scanRecord: ScanRecord + @MockK lateinit var device: BluetoothDevice + + val factory = AirPodsFactory( + proximityPairingDecoder = ProximityPairing.Decoder(), + continuityProtocolDecoder = ContinuityProtocol.Decoder(), + ) + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { scanResult.scanRecord } returns scanRecord + every { scanResult.rssi } returns -66 + every { scanResult.timestampNanos } returns 136136027721826 + every { scanResult.device } returns device + every { device.address } returns "77:49:4C:D8:25:0C" + } + + private suspend inline fun create(hex: String, block: T.() -> Unit) { + val trimmed = hex + .replace(" ", "") + .replace(">", "") + .replace("<", "") + require(trimmed.length % 2 == 0) { "Not a HEX string" } + val bytes = trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + mockData(bytes) + block.invoke(factory.create(scanResult) as T) + } + + private fun mockData(hex: String) { + val trimmed = hex + .replace(" ", "") + .replace(">", "") + .replace("<", "") + require(trimmed.length % 2 == 0) { "Not a HEX string" } + val bytes = trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + return mockData(bytes) + } + + private fun mockData(data: ByteArray) { + every { scanRecord.getManufacturerSpecificData(ContinuityProtocol.APPLE_COMPANY_IDENTIFIER) } returns data + } + + @Test + fun `test AirPodDevice - active microphone`() = runBlockingTest { + create("07 19 01 0E 20 >2B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { + // 00101011 + // --^----- + microPhonePod shouldBe AirPodsDevice.Pod.LEFT + } + create("07 19 01 0E 20 >0B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { + // 00001011 + // --^----- + microPhonePod shouldBe AirPodsDevice.Pod.RIGHT + } + } + + @Test + fun `test AirPodDevice - left pod ear status`() = runBlockingTest { + // Left Pod primary + create("07 19 01 0E 20 >22< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { + // 00100010 + // 765432¹0 + isLeftPodInEar shouldBe true + } + create("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { + // 00100000 + // 765432¹0 + isLeftPodInEar shouldBe false + } + + // Right Pod is primary + create("07 19 01 0E 20 >09< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { + // 00001001 + // 7654³210 + isLeftPodInEar shouldBe true + } + create("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { + // 00000001 + // 7654³210 + isLeftPodInEar shouldBe false + } + } + + @Test + fun `test AirPodDevice - right pod ear status`() = runBlockingTest { + // Left Pod primary + create("07 19 01 0E 20 >29< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { + // 00101001 + // 7654³210 + isRightPodInEar shouldBe true + } + create("07 19 01 0E 20 >21< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { + // 00100001 + // 7654³210 + isRightPodInEar shouldBe false + } + + // Right Pod is primary + create("07 19 01 0E 20 >03< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { + // 00000011 + // 765432¹0 + isRightPodInEar shouldBe true + } + create("07 19 01 0E 20 >01< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { + // 00000001 + // 765432¹0 + isRightPodInEar shouldBe false + } + } + + @Test + fun `test AirPodDevice - battery status`() = runBlockingTest { + // Right Pod is primary + create("07 19 01 0E 20 0B >98< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") { + // 88 10001000 + batteryLeftPodPercent shouldBe 0.9f + batteryRightPodPercent shouldBe 0.8f + } + // Left Pod primary + create("07 19 01 0E 20 2B >89< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") { + // F8 11111000 + batteryLeftPodPercent shouldBe 0.9f + batteryRightPodPercent shouldBe 0.8f + } + } + + @Test + fun `test AirPodDevice - pod charging`() = runBlockingTest { + /** + * Right pod is charging + */ + // This is the left + create("07 19 01 0E 20 51 89 >94< 52 00 00 F4 89 82 6D 3E 27 7F 26 62 57 D0 E2 A6 49 E9 35") { + // 1001 0100 + isLeftPodCharging shouldBe false + isRightPodCharging shouldBe true + } + // This is the right + create("07 19 01 0E 20 31 98 >A4< 01 00 00 31 B9 A0 C4 80 CD D1 CF B9 3A 9A 6D 48 31 08 EB") { + // 1010 0100 + isLeftPodCharging shouldBe false + isRightPodCharging shouldBe true + } + + /** + * Left pod is charging + */ + // This is the left + create("07 19 01 0E 20 71 98 >94< 52 00 05 A5 37 31 B2 BD 42 68 0C 64 FD 00 99 4A E5 3E F4") { + // 1001 0100 + isLeftPodCharging shouldBe true + isRightPodCharging shouldBe false + } + // This is the right + create("07 19 01 0E 20 11 89 >A4< 04 00 04 BA 79 1B C0 65 69 C6 9F 19 6E 37 7D 6D 86 8D D9") { + // 1010 0100 + isLeftPodCharging shouldBe true + isRightPodCharging shouldBe false + } + + // Both charging + create("07 19 01 0E 20 55 88 >B4< 59 00 05 4B FC DF 68 28 A5 45 52 65 9C FE 51 86 3A B5 DB") { + // 1011 0100 + isLeftPodCharging shouldBe true + isRightPodCharging shouldBe true + } + // Both not charging + create("07 19 01 0E 20 00 F8 >8F< 03 00 05 4C 0F A0 C4 05 24 DD EB AF 92 99 FD 54 B1 06 48") { + // 1000 1111 + isLeftPodCharging shouldBe false + isRightPodCharging shouldBe false + } + } + + @Test + fun `test AirPodDevice - case charging`() = runBlockingTest { + create("07 19 01 0E 20 75 99 >B4< 31 00 05 77 C8 BA 0C 4E 1F BE AD 70 C5 40 71 D2 E9 17 A2") { + // 0011 0011 + isCaseCharging shouldBe false + } + create("07 19 01 0E 20 75 A9 >F4< 51 00 05 A0 37 92 35 49 79 CC DC 27 94 8E FB 72 12 94 52") { + // 0101 0011 + isCaseCharging shouldBe true + } + } + + @Test + fun `test AirPodDevice - case lid test`() = runBlockingTest { + // Lid open + create("07 19 01 0E 20 55 AA B4 >31< 00 00 A1 D0 BD 82 D3 52 86 CA FC 11 62 DC 42 C6 92 8E") { + // 31 0011 0001 + caseLidState shouldBe AirPodsDevice.LidState.OPEN + } + // Lid just closed + create("07 19 01 0E 20 55 AA B4 >39< 00 00 08 A6 DB 99 E0 5E 14 85 E5 C2 0B 68 D7 FF C3 A1") { + // 39 0011 1001 + caseLidState shouldBe AirPodsDevice.LidState.UNKNOWN + } + // Lid closed + create("07 19 01 0E 20 55 AA B4 38 00 00 F3 F7 08 3B 98 09 C0 DD E4 BD BD 84 55 56 8B 81") { + // 38 0011 1000 + caseLidState shouldBe AirPodsDevice.LidState.CLOSED + } + } + + @Test + fun `test AirPodDevice - connection state`() = runBlockingTest { + // Disconnected + create("07 19 01 0E 20 2B AA 8F 01 00 >00< 62 D4 BB F1 A7 F8 64 98 D2 C8 BD 7B 3A EF 2E 15") { + // 31 0011 0001 + connectionState shouldBe AirPodsDevice.ConnectionState.DISCONNECTED + } + // Connected idle + create("07 19 01 0E 20 2B AA 8F 01 00 >04< 1D 69 69 9C C2 51 F3 1F BF 6E 45 DA 90 4A A3 E3") { + // 39 0011 1001 + connectionState shouldBe AirPodsDevice.ConnectionState.IDLE + } + // Connected and playing music + create("07 19 01 0E 20 2B A9 8F 01 00 >05< 14 F7 CB 49 9F D3 B3 22 77 D2 22 F1 74 8C AC A6") { + // 38 0011 1000 + connectionState shouldBe AirPodsDevice.ConnectionState.MUSIC + } + // Connected and call active + create("07 19 01 0E 20 2B 99 8F 01 00 >06< 0F 4B 43 25 E0 4A 73 63 14 22 C2 3C 89 13 BD 97") { + // 38 0011 1000 + connectionState shouldBe AirPodsDevice.ConnectionState.CALL + } + // Connected and call active + create("07 19 01 0E 20 2B 99 8F 01 00 >07< E7 DF 76 44 85 B5 30 F4 95 14 02 DC A1 A4 8A 09") { + // 38 0011 1000 + connectionState shouldBe AirPodsDevice.ConnectionState.RINGING + } + // Switching? + create("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") { + // 38 0011 1000 + connectionState shouldBe AirPodsDevice.ConnectionState.HANGING_UP + } + } + + @Test + fun `create AirPodsGen1`() = runBlockingTest { + create("07 19 01 02 20 55 AA 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") { + this shouldBe instanceOf() + } + } + + @Test + fun `create AirPodsPro`() = runBlockingTest { + create("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") { + this shouldBe instanceOf() + } + } + + @Test + fun `unknown AppleDevice`() = runBlockingTest { + create("07 19 01 FF FF 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") { + this shouldBe instanceOf() + } + } + + @Test + fun `invalid data`() = runBlockingTest { + create("abcd") { + this shouldBe null + } + } +} \ No newline at end of file diff --git a/app/src/test/java/eu/darken/cap/pods/core/airpods/BaseAirPodsTest.kt b/app/src/test/java/eu/darken/cap/pods/core/airpods/BaseAirPodsTest.kt new file mode 100644 index 00000000..4358e547 --- /dev/null +++ b/app/src/test/java/eu/darken/cap/pods/core/airpods/BaseAirPodsTest.kt @@ -0,0 +1,58 @@ +package eu.darken.cap.pods.core.airpods + +import android.bluetooth.BluetoothDevice +import android.bluetooth.le.ScanRecord +import android.bluetooth.le.ScanResult +import eu.darken.cap.pods.core.PodDevice +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.jupiter.api.BeforeEach +import testhelper.BaseTest + +abstract class BaseAirPodsTest : BaseTest() { + + @MockK lateinit var scanResult: ScanResult + @MockK lateinit var scanRecord: ScanRecord + @MockK lateinit var device: BluetoothDevice + + val factory = AirPodsFactory( + proximityPairingDecoder = ProximityPairing.Decoder(), + continuityProtocolDecoder = ContinuityProtocol.Decoder(), + ) + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { scanResult.scanRecord } returns scanRecord + every { scanResult.rssi } returns -66 + every { scanResult.timestampNanos } returns 136136027721826 + every { scanResult.device } returns device + every { device.address } returns "77:49:4C:D8:25:0C" + } + + suspend inline fun create(hex: String, block: T.() -> Unit) { + val trimmed = hex + .replace(" ", "") + .replace(">", "") + .replace("<", "") + require(trimmed.length % 2 == 0) { "Not a HEX string" } + val bytes = trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + mockData(bytes) + block.invoke(factory.create(scanResult) as T) + } + + fun mockData(hex: String) { + val trimmed = hex + .replace(" ", "") + .replace(">", "") + .replace("<", "") + require(trimmed.length % 2 == 0) { "Not a HEX string" } + val bytes = trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + return mockData(bytes) + } + + fun mockData(data: ByteArray) { + every { scanRecord.getManufacturerSpecificData(ContinuityProtocol.APPLE_COMPANY_IDENTIFIER) } returns data + } +} \ No newline at end of file diff --git a/app/src/test/java/eu/darken/cap/pods/core/airpods/models/AirPodsGen1Test.kt b/app/src/test/java/eu/darken/cap/pods/core/airpods/models/AirPodsGen1Test.kt new file mode 100644 index 00000000..512ad1fb --- /dev/null +++ b/app/src/test/java/eu/darken/cap/pods/core/airpods/models/AirPodsGen1Test.kt @@ -0,0 +1,42 @@ +package eu.darken.cap.pods.core.airpods.models + +import eu.darken.cap.pods.core.airpods.AirPodsDevice +import eu.darken.cap.pods.core.airpods.BaseAirPodsTest +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.Test + +class AirPodsGen1Test : BaseAirPodsTest() { + + // Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269 + @Test + fun `fake airpods`() = runBlockingTest { + create("07 19 01 02 20 55 AF 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") { + rawPrefix shouldBe 0x01.toUByte() + rawDeviceModel shouldBe 0x0220.toUShort() + rawStatus shouldBe 0x55.toUByte() + rawPodsBattery shouldBe 0xAF.toUByte() + rawCaseBattery shouldBe 0x56.toUByte() + rawCaseLidState shouldBe 0x31.toUByte() + rawDeviceColor shouldBe 0x00.toUByte() + rawSuffix shouldBe 0x00.toUByte() + + batteryLeftPodPercent shouldBe 1.0f + batteryRightPodPercent shouldBe null + + isCaseCharging shouldBe true + isLeftPodCharging shouldBe false + isRightPodCharging shouldBe true + + isLeftPodInEar shouldBe false + isRightPodInEar shouldBe false + batteryCasePercent shouldBe 0.6f + + caseLidState shouldBe AirPodsDevice.LidState.OPEN + + connectionState shouldBe AirPodsDevice.ConnectionState.DISCONNECTED + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + } +} \ No newline at end of file diff --git a/app/src/test/java/eu/darken/cap/pods/core/airpods/models/AirPodsGen2Test.kt b/app/src/test/java/eu/darken/cap/pods/core/airpods/models/AirPodsGen2Test.kt new file mode 100644 index 00000000..8f732edc --- /dev/null +++ b/app/src/test/java/eu/darken/cap/pods/core/airpods/models/AirPodsGen2Test.kt @@ -0,0 +1,41 @@ +package eu.darken.cap.pods.core.airpods.models + +import eu.darken.cap.pods.core.airpods.AirPodsDevice +import eu.darken.cap.pods.core.airpods.BaseAirPodsTest +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.Test + +class AirPodsGen2Test : BaseAirPodsTest() { + + @Test + fun `random Neighbor AirPodsGen2`() = runBlockingTest { + create("07 19 01 0F 20 02 F9 8F 01 00 05 F2 7E 14 E0 54 0A 53 69 5B 7D F2 15 1F D7 B1 12") { + rawPrefix shouldBe 0x01.toUByte() + rawDeviceModel shouldBe 0x0F20.toUShort() + rawStatus shouldBe 0x02.toUByte() + rawPodsBattery shouldBe 0xF9.toUByte() + rawCaseBattery shouldBe 0x8F.toUByte() + rawCaseLidState shouldBe 0x01.toUByte() + rawDeviceColor shouldBe 0x00.toUByte() + rawSuffix shouldBe 0x05.toUByte() + + batteryLeftPodPercent shouldBe null + batteryRightPodPercent shouldBe 0.9f + + isCaseCharging shouldBe false + isLeftPodCharging shouldBe false + isRightPodCharging shouldBe false + + isLeftPodInEar shouldBe false + isRightPodInEar shouldBe true + batteryCasePercent shouldBe null + + caseLidState shouldBe AirPodsDevice.LidState.NOT_IN_CASE + + connectionState shouldBe AirPodsDevice.ConnectionState.MUSIC + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + } +} \ No newline at end of file diff --git a/app/src/test/java/eu/darken/cap/pods/core/airpods/models/AirPodsProTest.kt b/app/src/test/java/eu/darken/cap/pods/core/airpods/models/AirPodsProTest.kt new file mode 100644 index 00000000..10da59ca --- /dev/null +++ b/app/src/test/java/eu/darken/cap/pods/core/airpods/models/AirPodsProTest.kt @@ -0,0 +1,194 @@ +package eu.darken.cap.pods.core.airpods.models + +import eu.darken.cap.pods.core.airpods.AirPodsDevice +import eu.darken.cap.pods.core.airpods.BaseAirPodsTest +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.Test + +class AirPodsProTest : BaseAirPodsTest() { + + @Test + fun `test AirPods Pro - default changed and in case`() = runBlockingTest { + create("07 19 01 0E 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") { + + rawPrefix shouldBe 0x01.toUByte() + rawDeviceModel shouldBe 0x0e20.toUShort() + rawStatus shouldBe 0x54.toUByte() + rawPodsBattery shouldBe 0xAA.toUByte() + rawCaseBattery shouldBe 0xB5.toUByte() + rawCaseLidState shouldBe 0x31.toUByte() + rawDeviceColor shouldBe 0x00.toUByte() + rawSuffix shouldBe 0x00.toUByte() + + microPhonePod shouldBe AirPodsDevice.Pod.RIGHT + + batteryLeftPodPercent shouldBe 1.0f + batteryRightPodPercent shouldBe 1.0f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe true + isLeftPodCharging shouldBe true + batteryCasePercent shouldBe 0.5f + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + } + + @Test + fun `test AirPods from my downstairs neighbour`() = runBlockingTest { + create("07 19 01 0E 20 00 F3 8F 02 00 04 79 C6 3F F9 C3 15 D9 11 A1 3C B1 58 66 B9 8B 67") { + microPhonePod shouldBe AirPodsDevice.Pod.RIGHT + + batteryLeftPodPercent shouldBe null + batteryRightPodPercent shouldBe 0.3f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + } + + // Test data from https://github.com/adolfintel/OpenPods/issues/34#issuecomment-565894487 + @Test + fun `various AirPods Pro messages`() = runBlockingTest { + create("0719010e202b668f01000500000000000000000000000000000000") { + batteryLeftPodPercent shouldBe 0.6f + batteryRightPodPercent shouldBe 0.6f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + + create("0719010e202b668f01000500000000000000000000000000000000") { + batteryLeftPodPercent shouldBe 0.6f + batteryRightPodPercent shouldBe 0.6f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + + create("0719010e202b668f01000400000000000000000000000000000000") { + batteryLeftPodPercent shouldBe 0.6f + batteryRightPodPercent shouldBe 0.6f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + + create("0719010e200b668f01000500000000000000000000000000000000") { + batteryLeftPodPercent shouldBe 0.6f + batteryRightPodPercent shouldBe 0.6f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + + create("0719010e2003668f01000500000000000000000000000000000000") { + batteryLeftPodPercent shouldBe 0.6f + batteryRightPodPercent shouldBe 0.6f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + + create("0719010e2001668f01000500000000000000000000000000000000") { + batteryLeftPodPercent shouldBe 0.6f + batteryRightPodPercent shouldBe 0.6f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + + create("0719010e2009668f01000500000000000000000000000000000000") { + batteryLeftPodPercent shouldBe 0.6f + batteryRightPodPercent shouldBe 0.6f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + + create("0719010e2053669653000500000000000000000000000000000000") { + batteryLeftPodPercent shouldBe 0.6f + batteryRightPodPercent shouldBe 0.6f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe true + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe 0.6f + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + + create("0719010e203366a602000500000000000000000000000000000000") { + batteryLeftPodPercent shouldBe 0.6f + batteryRightPodPercent shouldBe 0.6f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe true + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe 0.6f + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + + create("0719010e202b768f02000500000000000000000000000000000000") { + batteryLeftPodPercent shouldBe 0.6f + batteryRightPodPercent shouldBe 0.7f + + isCaseCharging shouldBe false + isRightPodCharging shouldBe false + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe null + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + } + + // Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269 + @Test + fun `fake airpods`() = runBlockingTest { + create("071901022055AA563100006FE4DF10AF106081033B76D9C7112288") { + batteryLeftPodPercent shouldBe 1.0f + batteryRightPodPercent shouldBe 1.0f + + isCaseCharging shouldBe true + isRightPodCharging shouldBe true + isLeftPodCharging shouldBe false + batteryCasePercent shouldBe 0.6f + + deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE + } + } +} \ No newline at end of file diff --git a/app/src/test/java/testhelper/BaseTest.kt b/app/src/test/java/testhelper/BaseTest.kt new file mode 100644 index 00000000..46b3b362 --- /dev/null +++ b/app/src/test/java/testhelper/BaseTest.kt @@ -0,0 +1,29 @@ +package testhelper + +import eu.darken.cap.common.debug.logging.Logging +import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.cap.common.debug.logging.log +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterAll +import testhelpers.logging.JUnitLogger + + +open class BaseTest { + init { + Logging.clearAll() + Logging.install(JUnitLogger()) + testClassName = this.javaClass.simpleName + } + + companion object { + private var testClassName: String? = null + + @JvmStatic + @AfterAll + fun onTestClassFinished() { + unmockkAll() + log(testClassName!!, VERBOSE) { "onTestClassFinished()" } + Logging.clearAll() + } + } +} diff --git a/app/src/test/java/testhelper/coroutine/CoroutinesTestExtension.kt b/app/src/test/java/testhelper/coroutine/CoroutinesTestExtension.kt new file mode 100644 index 00000000..900333f2 --- /dev/null +++ b/app/src/test/java/testhelper/coroutine/CoroutinesTestExtension.kt @@ -0,0 +1,26 @@ +package testhelper.coroutine + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +@ExperimentalCoroutinesApi +class CoroutinesTestExtension( + private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() +) : BeforeEachCallback, AfterEachCallback, TestCoroutineScope by TestCoroutineScope(dispatcher) { + + override fun beforeEach(context: ExtensionContext?) { + Dispatchers.setMain(dispatcher) + } + + override fun afterEach(context: ExtensionContext?) { + cleanupTestCoroutines() + Dispatchers.resetMain() + } +} diff --git a/app/src/test/java/testhelper/coroutine/TestDispatcherProvider.kt b/app/src/test/java/testhelper/coroutine/TestDispatcherProvider.kt new file mode 100644 index 00000000..de0e17cd --- /dev/null +++ b/app/src/test/java/testhelper/coroutine/TestDispatcherProvider.kt @@ -0,0 +1,23 @@ +package testhelper.coroutine + +import eu.darken.cap.common.coroutine.DispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +class TestDispatcherProvider(private val context: CoroutineContext? = null) : DispatcherProvider { + override val Default: CoroutineContext + get() = context ?: Dispatchers.Unconfined + override val Main: CoroutineContext + get() = context ?: Dispatchers.Unconfined + override val MainImmediate: CoroutineContext + get() = context ?: Dispatchers.Unconfined + override val Unconfined: CoroutineContext + get() = context ?: Dispatchers.Unconfined + override val IO: CoroutineContext + get() = context ?: Dispatchers.Unconfined +} + +fun CoroutineScope.asDispatcherProvider() = this.coroutineContext.asDispatcherProvider() + +fun CoroutineContext.asDispatcherProvider() = TestDispatcherProvider(context = this) diff --git a/app/src/test/java/testhelper/coroutine/TestExtensions.kt b/app/src/test/java/testhelper/coroutine/TestExtensions.kt new file mode 100644 index 00000000..624ff6f7 --- /dev/null +++ b/app/src/test/java/testhelper/coroutine/TestExtensions.kt @@ -0,0 +1,49 @@ +package testhelper.coroutine + +import eu.darken.cap.common.debug.logging.log +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.UncompletedCoroutinesError +import kotlinx.coroutines.test.runBlockingTest +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * If you have a test that uses a coroutine that never stops, you may use this. + */ + +@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +fun TestCoroutineScope.runBlockingTest2( + allowUncompleted: Boolean = false, + block: suspend TestCoroutineScope.() -> Unit +): Unit = runBlockingTest2( + allowUncompleted = allowUncompleted, + context = coroutineContext, + testBody = block +) + +fun runBlockingTest2( + allowUncompleted: Boolean = false, + context: CoroutineContext = EmptyCoroutineContext, + testBody: suspend TestCoroutineScope.() -> Unit +) { + try { + runBlocking { + try { + runBlockingTest( + context = context, + testBody = testBody + ) + } catch (e: UncompletedCoroutinesError) { + if (!allowUncompleted) throw e + else log { "Ignoring active job." } + } + } + } catch (e: Exception) { + if (!allowUncompleted || (e.message != "This job has not completed yet")) { + throw e + } + } +} + diff --git a/app/src/test/java/testhelper/flow/FlowTest.kt b/app/src/test/java/testhelper/flow/FlowTest.kt new file mode 100644 index 00000000..cdd9c494 --- /dev/null +++ b/app/src/test/java/testhelper/flow/FlowTest.kt @@ -0,0 +1,101 @@ +package testhelper.flow + +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 kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.TestCoroutineScope + +fun Flow.test( + tag: String? = null, + startOnScope: CoroutineScope = TestCoroutineScope() +): TestCollector = createTest(tag ?: "FlowTest").start(scope = startOnScope) + +fun Flow.createTest( + tag: String? = null +): TestCollector = TestCollector(this, tag ?: "FlowTest") + +class TestCollector( + private val flow: Flow, + private val tag: String + +) { + private var error: Throwable? = null + private lateinit var job: Job + private val cache = MutableSharedFlow( + replay = Int.MAX_VALUE, + extraBufferCapacity = Int.MAX_VALUE, + onBufferOverflow = BufferOverflow.SUSPEND + ) + private var latestInternal: T? = null + private val collectedValuesMutex = Mutex() + private val collectedValues = mutableListOf() + + var silent = false + + fun start(scope: CoroutineScope) = apply { + flow + .buffer(capacity = Int.MAX_VALUE) + .onStart { log(tag) { "Setting up." } } + .onCompletion { log(tag) { "Final." } } + .onEach { + collectedValuesMutex.withLock { + if (!silent) log(tag) { "Collecting: $it" } + latestInternal = it + collectedValues.add(it) + cache.emit(it) + } + } + .catch { e -> + log(tag, WARN) { "Caught error: ${e.asLog()}" } + error = e + } + .launchIn(scope) + .also { job = it } + } + + fun emissions(): Flow = cache + + val latestValue: T? + get() = collectedValues.last() + + val latestValues: List + get() = collectedValues + + fun await( + timeout: Long = 10_000, + condition: (List, T) -> Boolean + ): T = runBlocking { + withTimeout(timeMillis = timeout) { + emissions().first { + condition(collectedValues, it) + } + } + } + + suspend fun awaitFinal(cancel: Boolean = false) = apply { + if (cancel) cancel() + try { + job.join() + } catch (e: Exception) { + error = e + } + } + + suspend fun assertNoErrors() = apply { + awaitFinal() + require(error == null) { "Error was not null: $error" } + } + + fun cancel() { + if (job.isCompleted) throw IllegalStateException("Flow is already canceled.") + + runBlocking { + job.cancelAndJoin() + } + } +} diff --git a/app/src/test/java/testhelper/livedata/InstantExecutorExtension.kt b/app/src/test/java/testhelper/livedata/InstantExecutorExtension.kt new file mode 100644 index 00000000..debd59d9 --- /dev/null +++ b/app/src/test/java/testhelper/livedata/InstantExecutorExtension.kt @@ -0,0 +1,26 @@ +package testhelper.livedata + +import androidx.arch.core.executor.ArchTaskExecutor +import androidx.arch.core.executor.TaskExecutor +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance().setDelegate( + object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + + override fun postToMainThread(runnable: Runnable) = runnable.run() + + override fun isMainThread(): Boolean = true + } + ) + } + + override fun afterEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance().setDelegate(null) + } +} diff --git a/app/src/test/java/testhelper/preferences/MockSharedPreferencesTest.kt b/app/src/test/java/testhelper/preferences/MockSharedPreferencesTest.kt new file mode 100644 index 00000000..f69e990e --- /dev/null +++ b/app/src/test/java/testhelper/preferences/MockSharedPreferencesTest.kt @@ -0,0 +1,22 @@ +package testhelper.preferences + +import androidx.core.content.edit +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelper.BaseTest +import testhelpers.preferences.MockSharedPreferences + +class MockSharedPreferencesTest : BaseTest() { + + private fun createInstance() = MockSharedPreferences() + + @Test + fun `test boolean insertion`() { + val prefs = createInstance() + prefs.dataMapPeek shouldBe emptyMap() + prefs.getBoolean("key", true) shouldBe true + prefs.edit { putBoolean("key", false) } + prefs.getBoolean("key", true) shouldBe false + prefs.dataMapPeek["key"] shouldBe false + } +} diff --git a/app/src/testShared/java/testhelpers/BaseTestInstrumentation.kt b/app/src/testShared/java/testhelpers/BaseTestInstrumentation.kt new file mode 100644 index 00000000..8f9a7776 --- /dev/null +++ b/app/src/testShared/java/testhelpers/BaseTestInstrumentation.kt @@ -0,0 +1,29 @@ +package testhelpers + +import eu.darken.cap.common.debug.logging.Logging +import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.cap.common.debug.logging.log +import io.mockk.unmockkAll +import org.junit.AfterClass +import testhelpers.logging.JUnitLogger + +abstract class BaseTestInstrumentation { + + init { + Logging.clearAll() + Logging.install(JUnitLogger()) + testClassName = this.javaClass.simpleName + } + + companion object { + private var testClassName: String? = null + + @JvmStatic + @AfterClass + fun onTestClassFinished() { + unmockkAll() + log(testClassName!!, VERBOSE) { "onTestClassFinished()" } + Logging.clearAll() + } + } +} diff --git a/app/src/testShared/java/testhelpers/IsAUnitTest.kt b/app/src/testShared/java/testhelpers/IsAUnitTest.kt new file mode 100644 index 00000000..2af3e4cb --- /dev/null +++ b/app/src/testShared/java/testhelpers/IsAUnitTest.kt @@ -0,0 +1,3 @@ +package testhelpers + +class IsAUnitTest diff --git a/app/src/testShared/java/testhelpers/logging/JUnitLogger.kt b/app/src/testShared/java/testhelpers/logging/JUnitLogger.kt new file mode 100644 index 00000000..d3983964 --- /dev/null +++ b/app/src/testShared/java/testhelpers/logging/JUnitLogger.kt @@ -0,0 +1,13 @@ +package testhelpers.logging + +import eu.darken.cap.common.debug.logging.Logging + +class JUnitLogger(private val minLogLevel: Logging.Priority = Logging.Priority.VERBOSE) : Logging.Logger { + + override fun isLoggable(priority: Logging.Priority): Boolean = priority.intValue >= minLogLevel.intValue + + override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map?) { + println("${System.currentTimeMillis()} ${priority.shortLabel}/$tag: $message") + } + +} diff --git a/app/src/testShared/java/testhelpers/preferences/MockFlowPreference.kt b/app/src/testShared/java/testhelpers/preferences/MockFlowPreference.kt new file mode 100644 index 00000000..1e908c1b --- /dev/null +++ b/app/src/testShared/java/testhelpers/preferences/MockFlowPreference.kt @@ -0,0 +1,21 @@ +package testhelpers.preferences + +import eu.darken.cap.common.preferences.FlowPreference +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow + +fun mockFlowPreference( + defaultValue: T +): FlowPreference { + val instance = mockk>() + val flow = MutableStateFlow(defaultValue) + every { instance.flow } answers { flow } + every { instance.value } answers { flow.value } + every { instance.update(any()) } answers { + val updateCall = arg<(T) -> T>(0) + flow.value = updateCall(flow.value) + } + + return instance +} diff --git a/app/src/testShared/java/testhelpers/preferences/MockSharedPreferences.kt b/app/src/testShared/java/testhelpers/preferences/MockSharedPreferences.kt new file mode 100644 index 00000000..c094e560 --- /dev/null +++ b/app/src/testShared/java/testhelpers/preferences/MockSharedPreferences.kt @@ -0,0 +1,99 @@ +package testhelpers.preferences + +import android.content.SharedPreferences + +class MockSharedPreferences : SharedPreferences { + private val listeners = mutableListOf() + private val dataMap = mutableMapOf() + val dataMapPeek: Map + get() = dataMap.toMap() + + override fun getAll(): MutableMap = dataMap + + override fun getString(key: String, defValue: String?): String? = + dataMap[key] as? String ?: defValue + + override fun getStringSet(key: String, defValues: MutableSet?): MutableSet { + throw NotImplementedError() + } + + override fun getInt(key: String, defValue: Int): Int = + dataMap[key] as? Int ?: defValue + + override fun getLong(key: String, defValue: Long): Long = + dataMap[key] as? Long ?: defValue + + override fun getFloat(key: String, defValue: Float): Float { + throw NotImplementedError() + } + + override fun getBoolean(key: String, defValue: Boolean): Boolean = + dataMap[key] as? Boolean ?: defValue + + override fun contains(key: String): Boolean = dataMap.contains(key) + + override fun edit(): SharedPreferences.Editor = createEditor(dataMap.toMap()) { newData -> + dataMap.clear() + dataMap.putAll(newData) + } + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + listeners.add(listener) + } + + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + listeners.remove(listener) + } + + private fun createEditor( + toEdit: Map, + onSave: (Map) -> Unit + ): SharedPreferences.Editor { + return object : SharedPreferences.Editor { + private val editorData = toEdit.toMutableMap() + override fun putString(key: String, value: String?): SharedPreferences.Editor = apply { + value?.let { editorData[key] = it } ?: editorData.remove(key) + } + + override fun putStringSet( + key: String?, + values: MutableSet? + ): SharedPreferences.Editor { + throw NotImplementedError() + } + + override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply { + editorData[key] = value + } + + override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply { + editorData[key] = value + } + + override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply { + editorData[key] = value + } + + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply { + editorData[key] = value + } + + override fun remove(key: String): SharedPreferences.Editor = apply { + editorData.remove(key) + } + + override fun clear(): SharedPreferences.Editor = apply { + editorData.clear() + } + + override fun commit(): Boolean { + onSave(editorData) + return true + } + + override fun apply() { + onSave(editorData) + } + } + } +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..29712258 --- /dev/null +++ b/build.gradle @@ -0,0 +1,55 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + ext.buildConfig = [ + 'compileSdk': 31, + 'minSdk' : 21, + 'targetSdk' : 31, + + 'version' : [ + 'major': 0, + 'minor': 0, + 'patch': 1, + 'build': 0, + ], + ] + + ext.buildConfig.version['name'] = "${buildConfig.version.major}.${buildConfig.version.minor}.${buildConfig.version.patch}" + ext.buildConfig.version['fullName'] = "${buildConfig.version.name}.${buildConfig.version.build}" + ext.buildConfig.version['code'] = buildConfig.version.major * 1000000 + buildConfig.version.minor * 10000 + buildConfig.version.patch * 100 + buildConfig.version.build + + ext.versions = [ + 'kotlin' : [ + 'core' : '1.6.10', + 'coroutines': '1.5.1' + ], + 'dagger' : [ + 'core': '2.40.5' + ], + 'androidx' : [ + 'navigation': '2.3.5' + ], + ] + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:7.0.4' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin.core}" + classpath "com.google.dagger:hilt-android-gradle-plugin:${versions.dagger.core}" + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:${versions.androidx.navigation}" + classpath 'com.bugsnag:bugsnag-android-gradle-plugin:7.0.0' + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..25217527 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,19 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..f6b961fd Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..516b361d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu May 13 21:51:43 CEST 2021 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..cccdd3d5 --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..f9553162 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/local.properties b/local.properties new file mode 100644 index 00000000..a943e23f --- /dev/null +++ b/local.properties @@ -0,0 +1,10 @@ +## This file is automatically generated by Android Studio. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file should *NOT* be checked into Version Control Systems, +# as it contains information specific to your local configuration. +# +# Location of the SDK. This is only used by Gradle. +# For customization when using a Version Control System, please read the +# header note. +sdk.dir=/home/darken/Android/sdk \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..4fc256ae --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = "Companion App for AirPods" +include ':app'