mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
POC, decoding works.
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
build/
|
||||||
@@ -1 +1,37 @@
|
|||||||
# android-airpods-companion
|
# 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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-dontobfuscate
|
||||||
Vendored
+21
@@ -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
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
package="eu.darken.cap">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:name="eu.darken.cap.App"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="eu.darken.cap.main.ui.MainActivity"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name="eu.darken.cap.monitor.core.receiver.BluetoothEventReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="Service trigger">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.bluetooth.device.action.ACL_CONNECTED" />
|
||||||
|
<!-- <action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />-->
|
||||||
|
<!-- <action android:name="android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED" />-->
|
||||||
|
<!-- <action android:name="android.bluetooth.headset.profile.action.CONNECTION_STATE_CHANGED" />-->
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
android:foregroundServiceType="connectedDevice"
|
||||||
|
tools:node="merge" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
tools:node="remove"></provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<BugsnagLogger>,
|
||||||
|
private val bugsnagErrorHandler: Provider<BugsnagErrorHandler>,
|
||||||
|
private val nopBugsnagErrorHandler: Provider<NOPBugsnagErrorHandler>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 <T> LiveData<T>.observe2(fragment: Fragment, callback: (T) -> Unit) {
|
||||||
|
observe(fragment.viewLifecycleOwner) { callback.invoke(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
|
||||||
|
fragment: Fragment,
|
||||||
|
ui: VB,
|
||||||
|
crossinline callback: VB.(T) -> Unit
|
||||||
|
) {
|
||||||
|
observe(fragment.viewLifecycleOwner) { callback.invoke(ui, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> LiveData<T>.observe2(activity: AppCompatActivity, callback: (T) -> Unit) {
|
||||||
|
observe(activity) { callback.invoke(it) }
|
||||||
|
}
|
||||||
@@ -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<ScanFilter> = ProximityPairing.getBleScanFilter(),
|
||||||
|
mode: Int = ScanSettings.SCAN_MODE_LOW_LATENCY,
|
||||||
|
delay: Long = 1,
|
||||||
|
): Flow<List<ScanResult>> = 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<ScanResult>) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<Boolean> = 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<Set<BluetoothDevice2>> = 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?>(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BluetoothDevice2>
|
||||||
|
get() = profile.connectedDevices.map { BluetoothDevice2(it) }.toSet()
|
||||||
|
|
||||||
|
val isConnected: Boolean
|
||||||
|
get() = isConnectedAtomic.get()
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package eu.darken.cap.common.collections
|
||||||
|
|
||||||
|
inline fun <K, V> Map<K, V>.mutate(block: MutableMap<K, V>.() -> Unit): Map<K, V> {
|
||||||
|
return toMutableMap().apply(block).toMap()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package eu.darken.cap.common.coroutine
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<String> = ArrayDeque(BUFFER_SIZE + 1)
|
||||||
|
|
||||||
|
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<String, Any>?) {
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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<String, Any>?
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val internalLoggers = mutableListOf<Logger>()
|
||||||
|
|
||||||
|
val loggers: List<Logger>
|
||||||
|
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<String, Any>?,
|
||||||
|
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<String, Any>? = 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<String, Any>? = 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) { _, _ ->
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package eu.darken.cap.common.error
|
||||||
|
|
||||||
|
import eu.darken.cap.common.livedata.SingleLiveEvent
|
||||||
|
|
||||||
|
interface ErrorEventSource {
|
||||||
|
val errorEvents: SingleLiveEvent<Throwable>
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -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<Throwable>
|
||||||
|
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<out Throwable>): 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> = RuntimeException::class): Throwable =
|
||||||
|
if (!kClass.isInstance(this)) this else cause ?: this
|
||||||
@@ -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<T>(
|
||||||
|
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<Update<T>>(
|
||||||
|
replay = Int.MAX_VALUE,
|
||||||
|
extraBufferCapacity = Int.MAX_VALUE,
|
||||||
|
onBufferOverflow = BufferOverflow.SUSPEND
|
||||||
|
)
|
||||||
|
private val valueGuard = Mutex()
|
||||||
|
|
||||||
|
private val producer: Flow<State<T>> = channelFlow {
|
||||||
|
var currentValue = valueGuard.withLock {
|
||||||
|
lTag?.let { log(it, VERBOSE) { "Providing startValue..." } }
|
||||||
|
|
||||||
|
startValueProvider().also { startValue ->
|
||||||
|
val initializer = Update<T>(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<T> = 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<T> = 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<T> = 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<T>(
|
||||||
|
val onModify: suspend T.() -> T,
|
||||||
|
val onError: (suspend (Exception) -> Unit)? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class State<T>(
|
||||||
|
val value: T,
|
||||||
|
val error: Exception? = null,
|
||||||
|
val updatedBy: Update<T>,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package eu.darken.cap.common.flow
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package eu.darken.cap.common.flow
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
|
||||||
|
//@Suppress("UNCHECKED_CAST", "LongParameterList")
|
||||||
|
//inline fun <T1, T2, R> combine(
|
||||||
|
// flow: Flow<T1>,
|
||||||
|
// flow2: Flow<T2>,
|
||||||
|
// crossinline transform: suspend (T1, T2) -> R
|
||||||
|
//): Flow<R> = kotlinx.coroutines.flow.combine(
|
||||||
|
// flow,
|
||||||
|
// flow2
|
||||||
|
//) { args: Array<*> ->
|
||||||
|
// transform(
|
||||||
|
// args[0] as T1,
|
||||||
|
// args[1] as T2
|
||||||
|
// )
|
||||||
|
//}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST", "LongParameterList")
|
||||||
|
inline fun <T1, T2, T3, R> combine(
|
||||||
|
flow: Flow<T1>,
|
||||||
|
flow2: Flow<T2>,
|
||||||
|
flow3: Flow<T3>,
|
||||||
|
crossinline transform: suspend (T1, T2, T3) -> R
|
||||||
|
): Flow<R> = 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 <T1, T2, T3, T4, T5, R> combine(
|
||||||
|
flow: Flow<T1>,
|
||||||
|
flow2: Flow<T2>,
|
||||||
|
flow3: Flow<T3>,
|
||||||
|
flow4: Flow<T4>,
|
||||||
|
flow5: Flow<T5>,
|
||||||
|
crossinline transform: suspend (T1, T2, T3, T4, T5) -> R
|
||||||
|
): Flow<R> = 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 <T1, T2, T3, T4, T5, T6, R> combine(
|
||||||
|
flow: Flow<T1>,
|
||||||
|
flow2: Flow<T2>,
|
||||||
|
flow3: Flow<T3>,
|
||||||
|
flow4: Flow<T4>,
|
||||||
|
flow5: Flow<T5>,
|
||||||
|
flow6: Flow<T6>,
|
||||||
|
crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R
|
||||||
|
): Flow<R> = 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 <T1, T2, T3, T4, T5, T6, T7, R> combine(
|
||||||
|
flow: Flow<T1>,
|
||||||
|
flow2: Flow<T2>,
|
||||||
|
flow3: Flow<T3>,
|
||||||
|
flow4: Flow<T4>,
|
||||||
|
flow5: Flow<T5>,
|
||||||
|
flow6: Flow<T6>,
|
||||||
|
flow7: Flow<T7>,
|
||||||
|
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R
|
||||||
|
): Flow<R> = 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 <T1, T2, T3, T4, T5, T6, T7, T8, R> combine(
|
||||||
|
flow: Flow<T1>,
|
||||||
|
flow2: Flow<T2>,
|
||||||
|
flow3: Flow<T3>,
|
||||||
|
flow4: Flow<T4>,
|
||||||
|
flow5: Flow<T5>,
|
||||||
|
flow6: Flow<T6>,
|
||||||
|
flow7: Flow<T7>,
|
||||||
|
flow8: Flow<T8>,
|
||||||
|
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R
|
||||||
|
): Flow<R> = 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 <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, R> combine(
|
||||||
|
flow: Flow<T1>,
|
||||||
|
flow2: Flow<T2>,
|
||||||
|
flow3: Flow<T3>,
|
||||||
|
flow4: Flow<T4>,
|
||||||
|
flow5: Flow<T5>,
|
||||||
|
flow6: Flow<T6>,
|
||||||
|
flow7: Flow<T7>,
|
||||||
|
flow8: Flow<T8>,
|
||||||
|
flow9: Flow<T9>,
|
||||||
|
flow10: Flow<T10>,
|
||||||
|
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10) -> R
|
||||||
|
): Flow<R> = 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 <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, R> combine(
|
||||||
|
flow: Flow<T1>,
|
||||||
|
flow2: Flow<T2>,
|
||||||
|
flow3: Flow<T3>,
|
||||||
|
flow4: Flow<T4>,
|
||||||
|
flow5: Flow<T5>,
|
||||||
|
flow6: Flow<T6>,
|
||||||
|
flow7: Flow<T7>,
|
||||||
|
flow8: Flow<T8>,
|
||||||
|
flow9: Flow<T9>,
|
||||||
|
flow10: Flow<T10>,
|
||||||
|
flow11: Flow<T11>,
|
||||||
|
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11) -> R
|
||||||
|
): Flow<R> = 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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <T : Any> Flow<T>.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 <T : Any> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
|
||||||
|
scope = scope,
|
||||||
|
replay = 1,
|
||||||
|
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO)
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun <T> Flow<T>.withPrevious(): Flow<Pair<T?, T>> = this
|
||||||
|
.scan(Pair<T?, T?>(null, null)) { previous, current -> Pair(previous.second, current) }
|
||||||
|
.drop(1)
|
||||||
|
.map {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
it as Pair<T?, T>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun <T> Flow<T>.onError(block: suspend (Throwable) -> Unit) = this.catch {
|
||||||
|
block(it)
|
||||||
|
throw it
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Flow<T>.takeUntilAfter(predicate: suspend (T) -> Boolean) = transformWhile {
|
||||||
|
val fullfilled = predicate(it)
|
||||||
|
emit(it)
|
||||||
|
!fullfilled // We keep emitting until condition is fullfilled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Flow<T>.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T : BaseAdapter.VH> : RecyclerView.Adapter<T>() {
|
||||||
|
|
||||||
|
@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<Any>) {
|
||||||
|
onBindBaseVH(holder, position, payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun onBindBaseVH(holder: T, position: Int, payloads: MutableList<Any> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package eu.darken.cap.common.lists
|
||||||
|
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
|
||||||
|
interface BindableVH<ItemT, ViewBindingT : ViewBinding> {
|
||||||
|
|
||||||
|
val viewBinding: Lazy<ViewBindingT>
|
||||||
|
|
||||||
|
val onBindData: ViewBindingT.(item: ItemT, payloads: List<Any>) -> Unit
|
||||||
|
|
||||||
|
fun bind(item: ItemT, payloads: MutableList<Any> = mutableListOf()) = with(viewBinding.value) {
|
||||||
|
onBindData(item, payloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package eu.darken.cap.common.lists
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
|
interface DataAdapter<T> {
|
||||||
|
val data: MutableList<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <X, T> X.update(newData: List<T>?, notify: Boolean = true) where X : DataAdapter<T>, X : RecyclerView.Adapter<*> {
|
||||||
|
data.clear()
|
||||||
|
if (newData != null) data.addAll(newData)
|
||||||
|
if (notify) notifyDataSetChanged()
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package eu.darken.cap.common.lists
|
||||||
|
|
||||||
|
interface ListItem
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<A, T : DifferItem> 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<T>, A : ModularAdapter<*> {
|
||||||
|
private val callback = object : DiffUtil.ItemCallback<T>() {
|
||||||
|
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<T>()
|
||||||
|
private val listDiffer = AsyncListDiffer(adapter, callback)
|
||||||
|
|
||||||
|
val currentList: List<T>
|
||||||
|
get() = synchronized(internalList) { internalList }
|
||||||
|
|
||||||
|
init {
|
||||||
|
adapter.modules.add(0, StableIdMod(currentList))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitUpdate(newData: List<T>) {
|
||||||
|
listDiffer.submitList(newData) {
|
||||||
|
synchronized(internalList) {
|
||||||
|
internalList.clear()
|
||||||
|
internalList.addAll(newData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, T> X.update(newData: List<T>?)
|
||||||
|
where X : HasAsyncDiffer<T>, X : RecyclerView.Adapter<*> {
|
||||||
|
|
||||||
|
asyncDiffer.submitUpdate(newData ?: emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <A, T : DifferItem> A.setupDiffer(): AsyncDiffer<A, T>
|
||||||
|
where A : HasAsyncDiffer<T>, A : ModularAdapter<*> =
|
||||||
|
AsyncDiffer(this)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package eu.darken.cap.common.lists.differ
|
||||||
|
|
||||||
|
interface HasAsyncDiffer<T : DifferItem> {
|
||||||
|
|
||||||
|
val data: List<T>
|
||||||
|
get() = asyncDiffer.currentList
|
||||||
|
|
||||||
|
val asyncDiffer: AsyncDiffer<*, T>
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<VH : ModularAdapter.VH> : BaseAdapter<VH>() {
|
||||||
|
val modules = mutableListOf<Module>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
modules.filterIsInstance<Module.Setup>().forEach { it.onAdapterReady(this) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemId(position: Int): Long {
|
||||||
|
modules.filterIsInstance<Module.ItemId>().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<Module.Typing>().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<Module.Creator<VH>>().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<Any>) {
|
||||||
|
modules.filterIsInstance<Module.Binder<VH>>().forEach {
|
||||||
|
it.onBindModularVH(this, holder, position, payloads)
|
||||||
|
it.onPostBind(this, holder, position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||||
|
modules.filterIsInstance<Module.RecyclerViewLifecycle>().forEach { it.onAttachedToRecyclerView(recyclerView) }
|
||||||
|
super.onAttachedToRecyclerView(recyclerView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
|
||||||
|
modules.filterIsInstance<Module.RecyclerViewLifecycle>().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<T : VH> : Module {
|
||||||
|
fun onCreateModularVH(adapter: ModularAdapter<T>, parent: ViewGroup, viewType: Int): T?
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Binder<T : VH> : Module {
|
||||||
|
fun onBindModularVH(adapter: ModularAdapter<T>, vh: T, pos: Int, payloads: MutableList<Any>) {
|
||||||
|
// NOOP
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPostBind(adapter: ModularAdapter<T>, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package eu.darken.cap.common.lists.modular.mods
|
||||||
|
|
||||||
|
import eu.darken.cap.common.lists.modular.ModularAdapter
|
||||||
|
|
||||||
|
class ClickMod<VHT : ModularAdapter.VH> constructor(
|
||||||
|
private val listener: (VHT, Int) -> Unit
|
||||||
|
) : ModularAdapter.Module.Binder<VHT> {
|
||||||
|
|
||||||
|
override fun onBindModularVH(adapter: ModularAdapter<VHT>, vh: VHT, pos: Int, payloads: MutableList<Any>) {
|
||||||
|
vh.itemView.setOnClickListener { listener.invoke(vh, pos) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ItemT, HolderT> constructor(
|
||||||
|
private val data: List<ItemT>,
|
||||||
|
private val customBinder: (
|
||||||
|
(adapter: ModularAdapter<HolderT>, vh: HolderT, pos: Int, payload: MutableList<Any>) -> Unit
|
||||||
|
)? = null
|
||||||
|
) : ModularAdapter.Module.Binder<HolderT> where HolderT : BindableVH<ItemT, ViewBinding>, HolderT : ModularAdapter.VH {
|
||||||
|
|
||||||
|
override fun onBindModularVH(adapter: ModularAdapter<HolderT>, vh: HolderT, pos: Int, payloads: MutableList<Any>) {
|
||||||
|
customBinder?.invoke(adapter, vh, pos, mutableListOf()) ?: vh.bind(data[pos], payloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HolderT> constructor(
|
||||||
|
private val viewType: Int = 0,
|
||||||
|
private val factory: (ViewGroup) -> HolderT
|
||||||
|
) : ModularAdapter.Module.Creator<HolderT> where HolderT : ModularAdapter.VH {
|
||||||
|
|
||||||
|
override fun onCreateModularVH(adapter: ModularAdapter<HolderT>, parent: ViewGroup, viewType: Int): HolderT? {
|
||||||
|
if (this.viewType != viewType) return null
|
||||||
|
return factory.invoke(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ItemT : DifferItem> constructor(
|
||||||
|
private val data: List<ItemT>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HolderT> constructor(
|
||||||
|
private val typeResolver: (Int) -> Boolean,
|
||||||
|
private val factory: (ViewGroup) -> HolderT
|
||||||
|
) : ModularAdapter.Module.Typing,
|
||||||
|
ModularAdapter.Module.Creator<HolderT> 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<HolderT>,
|
||||||
|
parent: ViewGroup,
|
||||||
|
viewType: Int
|
||||||
|
): HolderT? {
|
||||||
|
if (adapter.determineOurViewType() != viewType) return null
|
||||||
|
return factory.invoke(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> : MutableLiveData<T>() {
|
||||||
|
|
||||||
|
private val pending = AtomicBoolean(false)
|
||||||
|
|
||||||
|
@MainThread
|
||||||
|
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <a href="https://issuetracker.google.com/issues/142847973">issue-142847973</a>
|
||||||
|
*/
|
||||||
|
@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)
|
||||||
|
}
|
||||||
@@ -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 <reified Args : NavArgs> SavedStateHandle.navArgs() = NavArgsLazy(Args::class) {
|
||||||
|
Bundle().apply {
|
||||||
|
keys().forEach {
|
||||||
|
when (val value = get<Any>(it)) {
|
||||||
|
is Serializable -> putSerializable(it, value)
|
||||||
|
is Parcelable -> putParcelable(it, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<in NavDirections>) = pub.postValue(this)
|
||||||
|
|
||||||
|
fun NavDirections.navVia(provider: NavEventSource) = this.navVia(provider.navEvents)
|
||||||
|
|
||||||
|
interface NavEventSource {
|
||||||
|
val navEvents: SingleLiveEvent<in NavDirections>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<T> 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<T> = 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 <reified T> basicReader(defaultValue: T): SharedPreferences.(key: String) -> T =
|
||||||
|
{ key ->
|
||||||
|
(this.all[key] ?: defaultValue) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified T> 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 <reified T : Any?> SharedPreferences.createFlowPreference(
|
||||||
|
key: String,
|
||||||
|
defaultValue: T = null as T
|
||||||
|
) = FlowPreference(
|
||||||
|
preferences = this,
|
||||||
|
key = key,
|
||||||
|
reader = FlowPreference.basicReader(defaultValue),
|
||||||
|
writer = FlowPreference.basicWriter()
|
||||||
|
)
|
||||||
|
|
||||||
|
inline fun <reified T : Any?> 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
|
||||||
|
)
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
@@ -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 <T, reified VB : ViewBinding?> LiveData<T>.observe2(
|
||||||
|
ui: VB,
|
||||||
|
crossinline callback: VB.(T) -> Unit
|
||||||
|
) {
|
||||||
|
observe(viewLifecycleOwner) { callback.invoke(ui, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <T> LiveData<T>.observe2(
|
||||||
|
crossinline callback: (T) -> Unit
|
||||||
|
) {
|
||||||
|
observe(viewLifecycleOwner) { callback.invoke(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
|
||||||
|
ui: VB,
|
||||||
|
crossinline callback: VB.(T) -> Unit
|
||||||
|
) {
|
||||||
|
observe(viewLifecycleOwner) { callback.invoke(ui, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<NavDirections?>()
|
||||||
|
override val errorEvents = SingleLiveEvent<Throwable>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
launchErrorHandler = CoroutineExceptionHandler { _, ex ->
|
||||||
|
log(TAG) { "Error during launch: ${ex.asLog()}" }
|
||||||
|
errorEvents.postValue(ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun <T> Flow<T>.launchInViewModel() = this
|
||||||
|
.setupCommonEventHandlers(TAG) { "launchInViewModel()" }
|
||||||
|
.launchIn(vmScope)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <T : Any> DynamicStateFlow<T>.asLiveData2() = flow.asLiveData2()
|
||||||
|
|
||||||
|
fun <T> Flow<T>.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 <T> Flow<T>.launchInViewModel() = this.launchIn(vmScope)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 : Fragment, reified BindingT : ViewBinding> 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 : Fragment, BindingT : ViewBinding> FragmentT.viewBinding(
|
||||||
|
bindingProvider: FragmentT.() -> BindingT,
|
||||||
|
lifecycleOwnerProvider: FragmentT.() -> LifecycleOwner
|
||||||
|
) = ViewBindingProperty(bindingProvider, lifecycleOwnerProvider)
|
||||||
|
|
||||||
|
class ViewBindingProperty<ComponentT : LifecycleOwner, BindingT : ViewBinding>(
|
||||||
|
private val bindingProvider: (ComponentT) -> BindingT,
|
||||||
|
private val lifecycleOwnerProvider: ComponentT.() -> LifecycleOwner
|
||||||
|
) : ReadOnlyProperty<ComponentT, BindingT> {
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <T> Flow<T>.asLiveData2() = this.asLiveData(context = dispatcherProvider.Default)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <reified VM : ViewModel> 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<VM : ViewModel>(
|
||||||
|
private val viewModelClass: KClass<VM>,
|
||||||
|
private val keyProducer: (() -> String)? = null,
|
||||||
|
private val storeProducer: () -> ViewModelStore,
|
||||||
|
private val factoryProducer: () -> ViewModelProvider.Factory
|
||||||
|
) : Lazy<VM> {
|
||||||
|
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 <reified VM : ViewModel> 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 <reified VM : ViewModel> 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 <VM : ViewModel> Fragment.createViewModelLazyKeyed(
|
||||||
|
viewModelClass: KClass<VM>,
|
||||||
|
keyProducer: (() -> String)? = null,
|
||||||
|
storeProducer: () -> ViewModelStore,
|
||||||
|
factoryProducer: (() -> ViewModelProvider.Factory)? = null
|
||||||
|
): Lazy<VM> {
|
||||||
|
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 <reified VM : ViewModel> ComponentActivity.viewModelsKeyed(
|
||||||
|
noinline keyProducer: (() -> String)? = null,
|
||||||
|
noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
|
||||||
|
): Lazy<VM> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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 <reified T : Parcelable> 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<T>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<MainAdapter.BaseVH<MainAdapter.Item, ViewBinding>>(),
|
||||||
|
HasAsyncDiffer<MainAdapter.Item> {
|
||||||
|
|
||||||
|
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<D : Item, B : ViewBinding>(
|
||||||
|
@LayoutRes layoutId: Int,
|
||||||
|
parent: ViewGroup
|
||||||
|
) : ModularAdapter.VH(layoutId, parent), BindableVH<D, B>
|
||||||
|
|
||||||
|
interface Item : DifferItem
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<String>
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Boolean> = flow {
|
||||||
|
emit(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
|
||||||
|
private val requiredPermissions: Flow<List<Permission>> = permissionCheckTrigger.map {
|
||||||
|
Permission.values()
|
||||||
|
.filter { hasApiLevel(it.minApiLevel) && !it.isGranted(context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestPermissionevent = SingleLiveEvent<Permission>()
|
||||||
|
|
||||||
|
val listItems: LiveData<List<MainAdapter.Item>> = combine(
|
||||||
|
enabledState,
|
||||||
|
requiredPermissions
|
||||||
|
) { state, permissions ->
|
||||||
|
val items = mutableListOf<MainAdapter.Item>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<PermissionCardVH.Item, MainPermissionItemBinding>(
|
||||||
|
R.layout.main_permission_item,
|
||||||
|
parent
|
||||||
|
) {
|
||||||
|
|
||||||
|
override val viewBinding = lazy {
|
||||||
|
MainPermissionItemBinding.bind(itemView)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val onBindData: MainPermissionItemBinding.(
|
||||||
|
item: Item,
|
||||||
|
payloads: List<Any>
|
||||||
|
) -> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ToggleCardVH.Item, MainToggleItemBinding>(
|
||||||
|
R.layout.main_toggle_item,
|
||||||
|
parent
|
||||||
|
) {
|
||||||
|
|
||||||
|
override val viewBinding = lazy {
|
||||||
|
MainToggleItemBinding.bind(itemView)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val onBindData: MainToggleItemBinding.(
|
||||||
|
item: Item,
|
||||||
|
payloads: List<Any>
|
||||||
|
) -> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package eu.darken.cap.monitor.core
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@MustBeDocumented
|
||||||
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
|
annotation class MonitorScope
|
||||||
@@ -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<List<PodDevice>> = bleScanner.scan()
|
||||||
|
.onStart { emptyList<PodDevice>() }
|
||||||
|
.map { scanResults ->
|
||||||
|
scanResults.mapNotNull { scanResult ->
|
||||||
|
podFactory.createPod(scanResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("Monitor", "PodMonitor")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>(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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MonitorWorker>().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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Unit> {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package eu.darken.cap.pods.core
|
||||||
|
|
||||||
|
import android.bluetooth.le.ScanResult
|
||||||
|
|
||||||
|
interface PodDevice {
|
||||||
|
|
||||||
|
val scanResult: ScanResult
|
||||||
|
|
||||||
|
interface Status {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user