diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..cf66c0b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +custom: + - "https://www.buymeacoffee.com/tydarken" diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..e9cbf53 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report about: Create a report to help us improve title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Setup** + +- Android version: [e.g. Android 11] +- App version [e.g. v1.0.0] +- Gear type [e.g. FPV Goggles V1] diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..8d1dbfb --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,28 @@ +name: Android CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: set up JDK 11 + uses: actions/setup-java@v2 + with: + java-version: '11' + distribution: 'adopt' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Run tests + run: ./gradlew testRelease + - name: Build with Gradle + run: ./gradlew assembleDebug diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml new file mode 100644 index 0000000..8115a4c --- /dev/null +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -0,0 +1,17 @@ +name: "Validate Gradle Wrapper" + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + validation: + name: "Validation" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: gradle/wrapper-validation-action@v1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbb2bb0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.iml +.gradle +/local.properties +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +/.idea/**/* +!/.idea/codeStyles/ +!/.idea/codeStyles/**/* +*.jks +.local/* diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..b8beb1c --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,252 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-kapt' + id 'kotlin-parcelize' +} + +apply plugin: 'dagger.hilt.android.plugin' +apply plugin: 'androidx.navigation.safeargs.kotlin' + +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.adsbc" + + compileSdkVersion buildConfig.compileSdk + + Properties bugsnagProps = new Properties() + def bugsnagPropsFile = new File(System.properties['user.home'], ".appconfig/${packageName}/bugsnag.properties") + if (bugsnagPropsFile.canRead()) bugsnagProps.load(new FileInputStream(bugsnagPropsFile)) + + defaultConfig { + applicationId "${packageName}" + + minSdkVersion buildConfig.minSdk + targetSdkVersion buildConfig.targetSdk + + versionCode buildConfig.version.code + versionName buildConfig.version.name + + testInstrumentationRunner "eu.darken.adsbc.HiltTestRunner" + + buildConfigField "String", "GITSHA", "\"${gitSha}\"" + buildConfigField "String", "BUILDTIME", "\"${buildTime}\"" + + manifestPlaceholders = [bugsnagApiKey: "fake"] + } + + 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 { + 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' + } + 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", + "-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()) + } + } + + gradle.projectsEvaluated { + tasks.withType(JavaCompile) { + options.compilerArgs << "-Xmaxerrs" << "1000" // or whatever number you want + } + } +} + +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" + } + + implementation("org.jsoup:jsoup:1.14.3") + + // Debugging + implementation ('com.bugsnag:bugsnag-android:5.9.2') + implementation 'com.getkeepsafe.relinker:relinker:1.4.3' + + implementation("com.squareup.moshi:moshi:1.13.0") + kapt("com.squareup.moshi:moshi-kotlin-codegen:1.13.0") + + // 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}" + + // Room + implementation "androidx.room:room-runtime:2.4.1" + kapt "androidx.room:room-compiler:2.4.1" + implementation "androidx.room:room-ktx:2.4.1" + + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-moshi:2.9.0' + implementation "com.squareup.okhttp3:logging-interceptor:4.9.1" + + // Support libs + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.appcompat:appcompat:1.4.1' + implementation 'androidx.annotation:annotation:1.3.0' + + implementation 'androidx.activity:activity-ktx:1.4.0' + implementation 'androidx.fragment:fragment-ktx:1.4.1' + + 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:${versions.androidx.navigation}" + implementation "androidx.navigation:navigation-ui-ktx:${versions.androidx.navigation}" + + implementation 'androidx.preference:preference-ktx:1.2.0' + + implementation 'androidx.core:core-splashscreen:1.0.0-beta01' + + def work_version = "2.7.1" + implementation "androidx.work:work-runtime:${work_version}" + testImplementation "androidx.work:work-testing:${work_version}" + + // UI + implementation 'androidx.constraintlayout:constraintlayout:2.1.3' + implementation 'com.google.android.material:material:1.6.0-alpha02' + + // 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:${versions.androidx.navigation}" + + testImplementation "io.kotest:kotest-runner-junit5:4.6.2" + testImplementation "io.kotest:kotest-assertions-core-jvm:4.6.2" + testImplementation "io.kotest:kotest-property-jvm:4.6.2" + androidTestImplementation "io.kotest:kotest-assertions-core-jvm:4.6.2" + androidTestImplementation "io.kotest:kotest-property-jvm:4.6.2" + + testImplementation 'android.arch.core:core-testing:1.1.1' + androidTestImplementation 'android.arch.core:core-testing:1.1.1' + debugImplementation 'androidx.test:core-ktx:1.4.0' + + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + + androidTestImplementation 'androidx.test:runner:1.4.0' + androidTestImplementation 'androidx.test:rules:1.4.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0' + androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0' + androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.4.0' +} \ No newline at end of file diff --git a/app/proguard-rules-debug.pro b/app/proguard-rules-debug.pro new file mode 100644 index 0000000..0674e77 --- /dev/null +++ b/app/proguard-rules-debug.pro @@ -0,0 +1 @@ +-dontobfuscate \ No newline at end of file diff --git a/app/proguard/proguard-rules.pro b/app/proguard/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/eu/darken/adsbc/ExampleInstrumentedTest.kt b/app/src/androidTest/java/eu/darken/adsbc/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..6b9ede3 --- /dev/null +++ b/app/src/androidTest/java/eu/darken/adsbc/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package eu.darken.adsbc + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("eu.darken.adsbc", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/eu/darken/adsbc/HiltTestRunner.kt b/app/src/androidTest/java/eu/darken/adsbc/HiltTestRunner.kt new file mode 100644 index 0000000..b313f08 --- /dev/null +++ b/app/src/androidTest/java/eu/darken/adsbc/HiltTestRunner.kt @@ -0,0 +1,13 @@ +package eu.darken.adsbc + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class HiltTestRunner : AndroidJUnitRunner() { + + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/eu/darken/adsbc/main/MainActivityTest.kt b/app/src/androidTest/java/eu/darken/adsbc/main/MainActivityTest.kt new file mode 100644 index 0000000..5c4f9f6 --- /dev/null +++ b/app/src/androidTest/java/eu/darken/adsbc/main/MainActivityTest.kt @@ -0,0 +1,43 @@ +package eu.darken.adsbc.main + +import android.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.liveData +import androidx.test.core.app.launchActivity +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import eu.darken.adsbc.main.ui.MainActivity +import eu.darken.adsbc.main.ui.MainActivityVM +import io.mockk.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import testhelper.BaseUITest + +@HiltAndroidTest +class MainActivityTest : BaseUITest() { + + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @BindValue + val mockViewModel = mockk(relaxed = true) + + @Before fun init() { + hiltRule.inject() + + mockViewModel.apply { + every { state } returns liveData { } + every { onGo() } just Runs + } + } + + @Test fun happyPath() { + launchActivity() + + verify { mockViewModel.onGo() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/eu/darken/adsbc/main/fragment/ExampleFragmentTest.kt b/app/src/androidTest/java/eu/darken/adsbc/main/fragment/ExampleFragmentTest.kt new file mode 100644 index 0000000..90a1f1d --- /dev/null +++ b/app/src/androidTest/java/eu/darken/adsbc/main/fragment/ExampleFragmentTest.kt @@ -0,0 +1,44 @@ +package eu.darken.adsbc.main.fragment + +import android.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.liveData +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import eu.darken.adsbc.main.ui.main.MainFragment +import eu.darken.adsbc.main.ui.main.MainFragmentVM +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import testhelper.BaseUITest +import testhelper.launchFragmentInHiltContainer + +@HiltAndroidTest +class ExampleFragmentTest : BaseUITest() { + + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @BindValue + val mockViewModel = mockk(relaxed = true) + + @Before fun init() { + hiltRule.inject() + + mockViewModel.apply { + every { state } returns liveData { } + } + } + + @Test fun happyPath() { + launchFragmentInHiltContainer() + + verify { mockViewModel.state } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/testhelper/BaseUITest.kt b/app/src/androidTest/java/testhelper/BaseUITest.kt new file mode 100644 index 0000000..3d4da5c --- /dev/null +++ b/app/src/androidTest/java/testhelper/BaseUITest.kt @@ -0,0 +1,5 @@ +package testhelper + +import testhelpers.BaseTestInstrumentation + +abstract class BaseUITest : BaseTestInstrumentation() diff --git a/app/src/androidTest/java/testhelper/EmptyFragmentActivity.kt b/app/src/androidTest/java/testhelper/EmptyFragmentActivity.kt new file mode 100644 index 0000000..de4f51f --- /dev/null +++ b/app/src/androidTest/java/testhelper/EmptyFragmentActivity.kt @@ -0,0 +1,41 @@ +package testhelper + +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.annotation.Nullable +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentFactory +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + + +class EmptyFragmentActivity : FragmentActivity() { + @SuppressLint("RestrictedApi") override fun onCreate(@Nullable savedInstanceState: Bundle?) { + FragmentFactoryHolderViewModel.getInstance(this).fragmentFactory?.let { + supportFragmentManager.fragmentFactory = it + } + + super.onCreate(savedInstanceState) + } +} + +class FragmentFactoryHolderViewModel : ViewModel() { + var fragmentFactory: FragmentFactory? = null + + override fun onCleared() { + super.onCleared() + fragmentFactory = null + } + + companion object { + fun getInstance(activity: FragmentActivity): FragmentFactoryHolderViewModel { + return ViewModelProvider(activity, FACTORY)[FragmentFactoryHolderViewModel::class.java] + } + + private val FACTORY: ViewModelProvider.Factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return FragmentFactoryHolderViewModel() as T + } + } + } +} diff --git a/app/src/androidTest/java/testhelper/HiltExtensions.kt b/app/src/androidTest/java/testhelper/HiltExtensions.kt new file mode 100644 index 0000000..b39c3b7 --- /dev/null +++ b/app/src/androidTest/java/testhelper/HiltExtensions.kt @@ -0,0 +1,39 @@ +package testhelper + + +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import eu.darken.adsbc.HiltTestActivity + +/** + * https://developer.android.com/training/dependency-injection/hilt-testing#launchfragment + */ +inline fun launchFragmentInHiltContainer( + fragmentArgs: Bundle? = null, + crossinline action: Fragment.() -> Unit = {} +) { + val startActivityIntent = Intent.makeMainActivity( + ComponentName( + ApplicationProvider.getApplicationContext(), + HiltTestActivity::class.java + ) + ) + + ActivityScenario.launch(startActivityIntent).onActivity { activity -> + activity.supportFragmentManager.fragmentFactory.instantiate(T::class.java.classLoader!!, T::class.java.name) + .apply { + arguments = fragmentArgs + + activity.supportFragmentManager + .beginTransaction() + .add(android.R.id.content, this, "") + .commitNow() + + action() + } + } +} \ No newline at end of file diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..fc13ae3 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/debug/java/eu/darken/adsbc/HiltTestActivity.kt b/app/src/debug/java/eu/darken/adsbc/HiltTestActivity.kt new file mode 100644 index 0000000..c9f976c --- /dev/null +++ b/app/src/debug/java/eu/darken/adsbc/HiltTestActivity.kt @@ -0,0 +1,7 @@ +package eu.darken.adsbc + +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class HiltTestActivity : AppCompatActivity() \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..21a233a --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..a64c023 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/core/Aircraft.kt b/app/src/main/java/eu/darken/adsbc/aircraft/core/Aircraft.kt new file mode 100644 index 0000000..c5503fc --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/core/Aircraft.kt @@ -0,0 +1,21 @@ +package eu.darken.adsbc.aircraft.core + +import java.time.Instant + +interface Aircraft { + val hexCode: String + val lastSeenAt: Instant + val totalMessages: Int + val reception: Float + val messageRate: Float + + val callsign: String? + + val squawkCode: String? + + val altitudeMeter: Long? + + val airSpeedKmh: Int? + + val distanceKmh: Float? +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/core/AircraftId.kt b/app/src/main/java/eu/darken/adsbc/aircraft/core/AircraftId.kt new file mode 100644 index 0000000..60eacf7 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/core/AircraftId.kt @@ -0,0 +1,11 @@ +package eu.darken.adsbc.aircraft.core + +import android.os.Parcelable +import androidx.annotation.Keep +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@Keep +@JsonClass(generateAdapter = true) +data class AircraftId(val id: String) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/core/AircraftStorage.kt b/app/src/main/java/eu/darken/adsbc/aircraft/core/AircraftStorage.kt new file mode 100644 index 0000000..3ac18b1 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/core/AircraftStorage.kt @@ -0,0 +1,46 @@ +package eu.darken.adsbc.aircraft.core + +import eu.darken.adsbc.common.collections.mutate +import eu.darken.adsbc.common.coroutine.AppScope +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.flow.DynamicStateFlow +import eu.darken.adsbc.feeder.core.FeederId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.plus +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AircraftStorage @Inject constructor( + @AppScope private val appScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) { + private val data = DynamicStateFlow>>( + parentScope = appScope + dispatcherProvider.IO + ) { + emptyMap() + } + + val allAircraft: Flow>> = data.flow + + suspend fun updateData(feederId: FeederId, aircraft: Collection) { + log(TAG) { "Updating aircraft for $feederId: ${aircraft.size} items" } + data.updateBlocking { + mutate { this[feederId] = aircraft } + } + } + + suspend fun deleteByFeederId(feederId: FeederId) { + log(TAG) { "Deleting all aircraft for $feederId" } + data.updateBlocking { + mutate { remove(feederId) } + } + } + + companion object { + private val TAG = logTag("Aircraft", "Storage") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/core/BaseAircraft.kt b/app/src/main/java/eu/darken/adsbc/aircraft/core/BaseAircraft.kt new file mode 100644 index 0000000..29ff3e3 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/core/BaseAircraft.kt @@ -0,0 +1,16 @@ +package eu.darken.adsbc.aircraft.core + +import java.time.Instant + +data class BaseAircraft( + override val hexCode: String, + override val lastSeenAt: Instant, + override val reception: Float, + override val totalMessages: Int, + override val messageRate: Float, + override val callsign: String? = null, + override val squawkCode: String? = null, + override val altitudeMeter: Long? = null, + override val airSpeedKmh: Int? = null, + override val distanceKmh: Float? = null +) : Aircraft \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/core/FeederAircraft.kt b/app/src/main/java/eu/darken/adsbc/aircraft/core/FeederAircraft.kt new file mode 100644 index 0000000..5552c1c --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/core/FeederAircraft.kt @@ -0,0 +1,8 @@ +package eu.darken.adsbc.aircraft.core + +import eu.darken.adsbc.feeder.core.FeederId + +data class FeederAircraft( + val feederId: FeederId, + val aircraft: Aircraft +) diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/core/api/ADSBxAircraftApi.kt b/app/src/main/java/eu/darken/adsbc/aircraft/core/api/ADSBxAircraftApi.kt new file mode 100644 index 0000000..b37333b --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/core/api/ADSBxAircraftApi.kt @@ -0,0 +1,76 @@ +package eu.darken.adsbc.aircraft.core.api + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import okhttp3.ResponseBody +import retrofit2.http.GET +import retrofit2.http.Query + +interface ADSBxAircraftApi { + + @JsonClass(generateAdapter = true) + data class Error( + @Json(name = "error") val error: String, // {"error": "Data Not Found - Anywhere ID [124124asd]"} + ) + + @GET("/uuid/?") + suspend fun getAircraft(@Query("feed") feedId: String): ResponseBody + + @JsonClass(generateAdapter = true) + data class AircraftPayload( + @Json(name = "now") val now: Double, + @Json(name = "messages") val messages: Int, + @Json(name = "aircraft") val aircraft: List, + ) { + + @JsonClass(generateAdapter = true) + data class TrackingData( + @Json(name = "hex") val hex: String, //"040170" + @Json(name = "type") val type: String, //"adsb_icao" + @Json(name = "flight") val flight: String?, //"ETH701 " + @Json(name = "alt_baro") val alt_baro: String?, //37000|ground + @Json(name = "alt_geom") val alt_geom: Long?, //37025 + @Json(name = "gs") val gs: Double?, //581.4 + @Json(name = "ias") val ias: Int?, //276 + @Json(name = "tas") val tas: Int?, //476 + @Json(name = "mach") val mach: Double?, //0.844 + @Json(name = "wd") val wd: Int?, //329 + @Json(name = "ws") val ws: Int?, //132 + @Json(name = "oat") val oat: Long?, //-64 + @Json(name = "tat") val tat: Long?, //-34 + @Json(name = "track") val track: Double?, //115.9 + @Json(name = "track_rate") val track_rate: Float?, //0.0 + @Json(name = "roll") val roll: Float?, //-0.18 + @Json(name = "mag_heading") val mag_heading: Float?, //104.59 + @Json(name = "true_heading") val true_heading: Float?, //107.22 + @Json(name = "baro_rate") val baro_rate: Int?, //0 + @Json(name = "geom_rate") val geom_rate: Int?, //0 + @Json(name = "squawk") val squawk: String?, //"2271" + @Json(name = "emergency") val emergency: String?, //"none" + @Json(name = "category") val category: String?, //"A5" + @Json(name = "nav_qnh") val nav_qnh: Float?, //1013.6 + @Json(name = "nav_altitude_mcp") val nav_altitude_mcp: Int?, //36992 + @Json(name = "nav_heading") val nav_heading: Float?, //104.77 + @Json(name = "lat") val lat: Float?, //49.717464 + @Json(name = "lon") val lon: Float?, //6.801576 + @Json(name = "nic") val nic: Int?, //8 + @Json(name = "rc") val rc: Int?, //186 + @Json(name = "seen_pos") val seen_pos: Float?, //0.6 + @Json(name = "version") val version: Int?, //2 + @Json(name = "nic_baro") val nic_baro: Int?, //1 + @Json(name = "nac_p") val nac_p: Int?, //9 + @Json(name = "nac_v") val nac_v: Int?, //2 + @Json(name = "sil") val sil: Int?, //3 + @Json(name = "sil_type") val sil_type: String?, //"perhour" + @Json(name = "gva") val gva: Int?, //2 + @Json(name = "sda") val sda: Int?, //2 + @Json(name = "alert") val alert: Int?, //0 + @Json(name = "spi") val spi: Int?, //0 + @Json(name = "mlat") val mlat: List, // ["lat","lon","nic","rc","nac_p","nac_v","sil","sil_type"] + @Json(name = "tisb") val tisb: List, + @Json(name = "messages") val messages: Int, //1536 + @Json(name = "seen") val seen: Float, //0.6 + @Json(name = "rssi") val rssi: Float, //-29.3 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/core/api/ADSBxAircraftEndpoint.kt b/app/src/main/java/eu/darken/adsbc/aircraft/core/api/ADSBxAircraftEndpoint.kt new file mode 100644 index 0000000..d56c8bf --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/core/api/ADSBxAircraftEndpoint.kt @@ -0,0 +1,77 @@ +package eu.darken.adsbc.aircraft.core.api + +import com.squareup.moshi.Moshi +import dagger.Reusable +import eu.darken.adsbc.aircraft.core.Aircraft +import eu.darken.adsbc.aircraft.core.BaseAircraft +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.feeder.core.ADSBxId +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.time.Instant +import javax.inject.Inject +import kotlin.math.roundToLong + +@Reusable +class ADSBxAircraftEndpoint @Inject constructor( + private val baseClient: OkHttpClient, + private val moshiConverterFactory: MoshiConverterFactory, + private val moshi: Moshi, + private val dispatcherProvider: DispatcherProvider, +) { + + private val aircraftAdapter = moshi.adapter(ADSBxAircraftApi.AircraftPayload::class.java) + private val errorAdapter = moshi.adapter(ADSBxAircraftApi.Error::class.java) + + private val api by lazy { + val configHttpClient = baseClient.newBuilder().apply { + + }.build() + + Retrofit.Builder() + .client(configHttpClient) + .baseUrl("https://globe.adsbexchange.com") + .addConverterFactory(moshiConverterFactory) + .build() + .create(ADSBxAircraftApi::class.java) + } + + + suspend fun getAircraft(id: ADSBxId): Collection = withContext(dispatcherProvider.IO) { + log(TAG) { "getAircrafts(id=$id)" } + + val body = api.getAircraft(id.value) + val bodyRaw = body.string() + + val container = if (bodyRaw.contains("\"error\"")) { + val error = errorAdapter.fromJson(bodyRaw) + throw IllegalArgumentException(error?.error) + } else { + aircraftAdapter.fromJson(bodyRaw)!! + } + val containerNow = Instant.ofEpochMilli((container.now * 1000L).roundToLong()) + container.aircraft.map { + val nowSeenAt = Instant.ofEpochMilli(((container.now - it.seen) * 1000L).roundToLong()) + + BaseAircraft( + hexCode = it.hex, + callsign = it.flight, + lastSeenAt = nowSeenAt, + totalMessages = it.messages, + messageRate = 0f, + squawkCode = it.squawk, + altitudeMeter = it.alt_geom, + distanceKmh = 0f, + reception = it.rssi + ) + } + } + + companion object { + private val TAG = logTag("Feeder", "Aircraft", "Endpoint") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/ui/AircraftAdapter.kt b/app/src/main/java/eu/darken/adsbc/aircraft/ui/AircraftAdapter.kt new file mode 100644 index 0000000..1f5d4c1 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/ui/AircraftAdapter.kt @@ -0,0 +1,78 @@ +package eu.darken.adsbc.aircraft.ui + +import android.annotation.SuppressLint +import android.icu.text.RelativeDateTimeFormatter +import android.view.ViewGroup +import eu.darken.adsbc.R +import eu.darken.adsbc.aircraft.core.Aircraft +import eu.darken.adsbc.common.lists.BindableVH +import eu.darken.adsbc.common.lists.differ.DifferItem +import eu.darken.adsbc.common.lists.differ.setupDiffer +import eu.darken.adsbc.common.lists.modular.mods.DataBinderMod +import eu.darken.adsbc.databinding.AircraftListLineBinding +import eu.darken.adsbc.feeder.core.SpottedAircraft +import eu.darken.bb.common.lists.differ.AsyncDiffer +import eu.darken.bb.common.lists.differ.HasAsyncDiffer +import eu.darken.bb.common.lists.modular.ModularAdapter +import eu.darken.bb.common.lists.modular.mods.SimpleVHCreatorMod +import java.time.Duration +import java.time.Instant +import javax.inject.Inject + +class AircraftAdapter @Inject constructor() : ModularAdapter(), + HasAsyncDiffer { + + override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer() + + override fun getItemCount(): Int = data.size + + init { + modules.add(DataBinderMod(data)) + modules.add(SimpleVHCreatorMod { AircraftVH(it) }) + } + + data class Item( + val spottedAircraft: SpottedAircraft, + val onClickAction: (Aircraft) -> Unit + ) : DifferItem { + override val stableId: Long = spottedAircraft.aircraft.hexCode.hashCode().toLong() + } + + class AircraftVH(parent: ViewGroup) : ModularAdapter.VH(R.layout.aircraft_list_line, parent), + BindableVH { + + override val viewBinding: Lazy = lazy { AircraftListLineBinding.bind(itemView) } + + private val lastSeenFormatter = RelativeDateTimeFormatter.getInstance() + + @SuppressLint("SetTextI18n") + override val onBindData: AircraftListLineBinding.( + item: Item, + payloads: List + ) -> Unit = { item, _ -> + val ac = item.spottedAircraft.aircraft + label.text = "${ac.callsign} (${ac.hexCode})" + + val timeAgo = Duration.between( + ac.lastSeenAt, + Instant.now() + ) + + lastSeen.text = lastSeenFormatter.format( + timeAgo.seconds.toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.SECONDS + ) + + val seenBy = item.spottedAircraft.feeders + .sortedByDescending { it.second.reception } + .joinToString(", ") { + "${it.first.label} (${it.second.reception} dBm)" + } + receptionStats.text = seenBy + aircraftStats.text = "SQAWK ${ac.squawkCode}" + + itemView.setOnClickListener { item.onClickAction(ac) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/ui/AircraftListFragment.kt b/app/src/main/java/eu/darken/adsbc/aircraft/ui/AircraftListFragment.kt new file mode 100644 index 0000000..c223f46 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/ui/AircraftListFragment.kt @@ -0,0 +1,36 @@ +package eu.darken.adsbc.aircraft.ui + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.R +import eu.darken.adsbc.common.WebpageTool +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.lists.differ.update +import eu.darken.adsbc.common.uix.Fragment3 +import eu.darken.adsbc.common.viewbinding.viewBinding +import eu.darken.adsbc.databinding.AircraftListFragmentBinding +import eu.darken.bb.common.lists.setupDefaults +import javax.inject.Inject + +@AndroidEntryPoint +class AircraftListFragment : Fragment3(R.layout.aircraft_list_fragment) { + + override val vm: AircraftListFragmentVM by viewModels() + override val ui: AircraftListFragmentBinding by viewBinding() + + @Inject lateinit var webpageTool: WebpageTool + @Inject lateinit var aircraftAdapter: AircraftAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + ui.list.setupDefaults(aircraftAdapter) + + vm.aircraft.observe2(ui) { aircraftAdapter.update(it) } + super.onViewCreated(view, savedInstanceState) + } + + companion object { + private val TAG = logTag("Aircraft", "List", "Fragment") + } +} diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/ui/AircraftListFragmentVM.kt b/app/src/main/java/eu/darken/adsbc/aircraft/ui/AircraftListFragmentVM.kt new file mode 100644 index 0000000..10aff7a --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/ui/AircraftListFragmentVM.kt @@ -0,0 +1,47 @@ +package eu.darken.adsbc.aircraft.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.uix.ViewModel3 +import eu.darken.adsbc.feeder.core.FeederRepo +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive +import java.time.Instant +import javax.inject.Inject +import kotlin.coroutines.coroutineContext + +@HiltViewModel +class AircraftListFragmentVM @Inject constructor( + handle: SavedStateHandle, + dispatcherProvider: DispatcherProvider, + private val feederRepo: FeederRepo, +) : ViewModel3(dispatcherProvider = dispatcherProvider) { + + private val ticker = flow { + while (coroutineContext.isActive) { + emit(Instant.now()) + delay(1000) + } + } + val aircraft: LiveData> = combine( + feederRepo.mergedAircrafts, + ticker + ) { aircraft, now -> + aircraft.map { spot -> + AircraftAdapter.Item( + spottedAircraft = spot, + ) { log(TAG) { "Aircraft clicked: $it" } } + } + .sortedByDescending { it.spottedAircraft.feeders.size } + }.asLiveData2() + + companion object { + private val TAG = logTag("Aircraft", "List", "VM") + } +} diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/ui/actions/ActionsAdapter.kt b/app/src/main/java/eu/darken/adsbc/aircraft/ui/actions/ActionsAdapter.kt new file mode 100644 index 0000000..52a6aaf --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/ui/actions/ActionsAdapter.kt @@ -0,0 +1,33 @@ +package eu.darken.adsbc.aircraft.ui.actions + +import android.view.ViewGroup +import eu.darken.adsbc.common.lists.differ.setupDiffer +import eu.darken.adsbc.common.lists.modular.mods.DataBinderMod +import eu.darken.adsbc.common.ui.Confirmable +import eu.darken.adsbc.common.ui.ConfirmableActionAdapterVH +import eu.darken.bb.common.lists.differ.AsyncDiffer +import eu.darken.bb.common.lists.differ.HasAsyncDiffer +import eu.darken.bb.common.lists.modular.ModularAdapter +import eu.darken.bb.common.lists.modular.mods.SimpleVHCreatorMod +import javax.inject.Inject + +class ActionsAdapter @Inject constructor() : + ModularAdapter(), + HasAsyncDiffer> { + + override val asyncDiffer: AsyncDiffer> = setupDiffer() + + override fun getItemCount(): Int = data.size + + init { + modules.add(DataBinderMod(data)) + modules.add(SimpleVHCreatorMod { VH(it) }) + } + + class VH(parent: ViewGroup) : ConfirmableActionAdapterVH(parent) { + override fun getIcon(item: AircraftAction): Int = item.iconRes + + override fun getLabel(item: AircraftAction): String = getString(item.labelRes) + + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/ui/actions/AircraftAction.kt b/app/src/main/java/eu/darken/adsbc/aircraft/ui/actions/AircraftAction.kt new file mode 100644 index 0000000..01bec30 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/ui/actions/AircraftAction.kt @@ -0,0 +1,13 @@ +package eu.darken.adsbc.aircraft.ui.actions + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import eu.darken.adsbc.R +import eu.darken.adsbc.common.DialogActionEnum + +enum class AircraftAction constructor( + @DrawableRes val iconRes: Int, + @StringRes val labelRes: Int +) : DialogActionEnum { + DETAILS(R.drawable.ic_baseline_drive_file_rename_outline_24, R.string.aircraft_details_action), +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/ui/actions/AircraftActionDialog.kt b/app/src/main/java/eu/darken/adsbc/aircraft/ui/actions/AircraftActionDialog.kt new file mode 100644 index 0000000..f535991 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/ui/actions/AircraftActionDialog.kt @@ -0,0 +1,41 @@ +package eu.darken.adsbc.aircraft.ui.actions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.common.lists.differ.update +import eu.darken.adsbc.common.uix.BottomSheetDialogFragment2 +import eu.darken.adsbc.databinding.AircraftListActionDialogBinding +import eu.darken.bb.common.lists.setupDefaults +import javax.inject.Inject + +@AndroidEntryPoint +class AircraftActionDialog : BottomSheetDialogFragment2() { + override val vm: AircraftActionDialogVM by viewModels() + override lateinit var ui: AircraftListActionDialogBinding + @Inject lateinit var actionsAdapter: ActionsAdapter + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + ui = AircraftListActionDialogBinding.inflate(inflater, container, false) + return ui.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + ui.recyclerview.setupDefaults(actionsAdapter) + + vm.state.observe2(ui) { state -> + feederName.text = state.label + + actionsAdapter.update(state.allowedActions) + + ui.recyclerview.visibility = if (state.loading) View.INVISIBLE else View.VISIBLE + ui.progressCircular.visibility = if (state.loading) View.VISIBLE else View.INVISIBLE + if (state.finished) dismissAllowingStateLoss() + } + + super.onViewCreated(view, savedInstanceState) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/aircraft/ui/actions/AircraftActionDialogVM.kt b/app/src/main/java/eu/darken/adsbc/aircraft/ui/actions/AircraftActionDialogVM.kt new file mode 100644 index 0000000..9f6a5d6 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/aircraft/ui/actions/AircraftActionDialogVM.kt @@ -0,0 +1,76 @@ +package eu.darken.adsbc.aircraft.ui.actions + +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.adsbc.aircraft.ui.actions.AircraftAction.DETAILS +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.flow.DynamicStateFlow +import eu.darken.adsbc.common.navigation.navArgs +import eu.darken.adsbc.common.ui.Confirmable +import eu.darken.adsbc.common.uix.ViewModel3 +import eu.darken.adsbc.stats.core.storage.StatsStorage +import javax.inject.Inject + +@HiltViewModel +class AircraftActionDialogVM @Inject constructor( + handle: SavedStateHandle, + dispatcherProvider: DispatcherProvider, + private val feederStorage: StatsStorage, +) : ViewModel3(dispatcherProvider) { + + private val navArgs by handle.navArgs() + private val aircraftId = navArgs.aircraftId + private val stateUpdater = DynamicStateFlow(TAG, vmScope) { State(loading = true) } + + val state = stateUpdater.asLiveData2() + + init { + launch { +// val feeder = feederRepo.feeder(feederId).first() +// +// val actions = listOf( +// Confirmable(DETAILS) { aircraftAction(it) }, +// ) +// +// stateUpdater.updateBlocking { +// if (feeder == null) { +// copy(loading = true, finished = true) +// } else { +// copy( +// label = feeder.labelOrId, +// loading = false, +// allowedActions = actions +// ) +// } +// } + } + } + + private fun aircraftAction(action: AircraftAction) = launch { + stateUpdater.updateBlocking { copy(loading = true) } + launch { + try { + when (action) { + DETAILS -> { + + } + } + } finally { + stateUpdater.updateBlocking { copy(loading = false, finished = true) } + } + } + } + + data class State( + val loading: Boolean = false, + val finished: Boolean = false, + val label: String = "", + val allowedActions: List> = listOf() + ) + + companion object { + private val TAG = logTag("Aircraft", "ActionDialog", "VM") + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/BuildConfigWrap.kt b/app/src/main/java/eu/darken/adsbc/common/BuildConfigWrap.kt new file mode 100644 index 0000000..1a4dcda --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/BuildConfigWrap.kt @@ -0,0 +1,18 @@ +package eu.darken.adsbc.common + +import eu.darken.adsbc.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 + + val VERSION_DESCRIPTION: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA]" +} diff --git a/app/src/main/java/eu/darken/adsbc/common/BuildWrap.kt b/app/src/main/java/eu/darken/adsbc/common/BuildWrap.kt new file mode 100644 index 0000000..dfb1178 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/BuildWrap.kt @@ -0,0 +1,16 @@ +package eu.darken.adsbc.common + +import android.os.Build + +// Can't be const because that prevents them from being mocked in tests +@Suppress("MayBeConstant") +object BuildWrap { + + val VERSION = VersionWrap + + object VersionWrap { + val SDK_INT = Build.VERSION.SDK_INT + } +} + +fun hasApiLevel(level: Int): Boolean = BuildWrap.VERSION.SDK_INT >= level diff --git a/app/src/main/java/eu/darken/adsbc/common/ClipboardHelper.kt b/app/src/main/java/eu/darken/adsbc/common/ClipboardHelper.kt new file mode 100644 index 0000000..674d095 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/ClipboardHelper.kt @@ -0,0 +1,51 @@ +package eu.darken.adsbc.common + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Handler +import android.os.Looper +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.adsbc.R +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import java.util.concurrent.locks.ReentrantLock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.concurrent.withLock + +@Singleton +class ClipboardHelper @Inject constructor( + @ApplicationContext private val context: Context +) { + private val clipboard: ClipboardManager by lazy { + return@lazy if (Looper.getMainLooper() == Looper.myLooper()) { + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + } else { + // java.lang.RuntimeException ยท Can't create handler inside thread that has not called Looper.prepare() + log(TAG) { "Clipboard is not initialized on the main thread, applying workaround" } + val lock = ReentrantLock() + val lockCondition = lock.newCondition() + + var clipboardManager: ClipboardManager? = null + + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + lock.withLock { lockCondition.signal() } + } + + lock.withLock { lockCondition.await() } + + clipboardManager!! + } + } + + fun copyToClipboard(text: String) { + val clip = ClipData.newPlainText(context.getString(R.string.app_name), text) + clipboard.setPrimaryClip(clip) + } + + companion object { + private val TAG = logTag("ClipboardHelper") + } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/ColorExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/ColorExtensions.kt new file mode 100644 index 0000000..3922b0d --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/ColorExtensions.kt @@ -0,0 +1,13 @@ +package eu.darken.adsbc.common + +import android.content.Context +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat + +fun colorString(context: Context, @ColorRes colorRes: Int, string: String): SpannableString { + val colored = SpannableString(string) + colored.setSpan(ForegroundColorSpan(ContextCompat.getColor(context, colorRes)), 0, string.length, 0) + return colored +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/ContextExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/ContextExtensions.kt new file mode 100644 index 0000000..8fe8c43 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/ContextExtensions.kt @@ -0,0 +1,39 @@ +package eu.darken.adsbc.common + +import android.annotation.SuppressLint +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) { + if (hasApiLevel(26)) startForegroundService(intent) else startService(intent) +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/DialogActionEnum.kt b/app/src/main/java/eu/darken/adsbc/common/DialogActionEnum.kt new file mode 100644 index 0000000..75ec079 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/DialogActionEnum.kt @@ -0,0 +1,8 @@ +package eu.darken.adsbc.common + +import eu.darken.adsbc.common.lists.differ.DifferItem + +interface DialogActionEnum : DifferItem { + override val stableId: Long + get() = (this as Enum<*>).ordinal.toLong() +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/EmailTool.kt b/app/src/main/java/eu/darken/adsbc/common/EmailTool.kt new file mode 100644 index 0000000..f98a288 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/EmailTool.kt @@ -0,0 +1,33 @@ +package eu.darken.adsbc.common + +import android.content.Context +import android.content.Intent +import dagger.Reusable +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +@Reusable +class EmailTool @Inject constructor( + @ApplicationContext val context: Context +) { + + fun build(email: Email, offerChooser: Boolean = false): Intent { + val intent = Intent(Intent.ACTION_SEND) + intent.type = "message/rfc822" + intent.putExtra(Intent.EXTRA_EMAIL, email.receipients.toTypedArray()) + + intent.addCategory(Intent.CATEGORY_DEFAULT) + + intent.putExtra(Intent.EXTRA_SUBJECT, email.subject) + intent.putExtra(Intent.EXTRA_TEXT, email.body) + + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return if (offerChooser) Intent.createChooser(intent, null) else intent + } + + data class Email( + val receipients: List, + val subject: String, + val body: String + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/InstallId.kt b/app/src/main/java/eu/darken/adsbc/common/InstallId.kt new file mode 100644 index 0000000..9226c31 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/InstallId.kt @@ -0,0 +1,42 @@ +package eu.darken.adsbc.common + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import java.io.File +import java.util.* +import java.util.regex.Pattern +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class InstallId @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val installIDFile = File(context.filesDir, INSTALL_ID_FILENAME) + val id: String by lazy { + val existing = if (installIDFile.exists()) { + installIDFile.readText().also { + if (!UUID_PATTERN.matcher(it).matches()) throw IllegalStateException("Invalid InstallID: $it") + } + } else { + null + } + + return@lazy existing ?: UUID.randomUUID().toString().also { + log(TAG) { "New install ID created: $it" } + installIDFile.writeText(it) + } + } + + companion object { + private val TAG: String = logTag("InstallID") + private val UUID_PATTERN = Pattern.compile( + "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + ) + + private const val INSTALL_ID_FILENAME = "installid" + } +} + diff --git a/app/src/main/java/eu/darken/adsbc/common/LiveDataExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/LiveDataExtensions.kt new file mode 100644 index 0000000..23e8032 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/LiveDataExtensions.kt @@ -0,0 +1,23 @@ +package eu.darken.adsbc.common + +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import androidx.viewbinding.ViewBinding + + +fun LiveData.observe2(fragment: Fragment, callback: (T) -> Unit) { + observe(fragment.viewLifecycleOwner) { callback.invoke(it) } +} + +inline fun LiveData.observe2( + fragment: Fragment, + ui: VB, + crossinline callback: VB.(T) -> Unit +) { + observe(fragment.viewLifecycleOwner) { callback.invoke(ui, it) } +} + +fun LiveData.observe2(activity: AppCompatActivity, callback: (T) -> Unit) { + observe(activity) { callback.invoke(it) } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/UnitConverter.kt b/app/src/main/java/eu/darken/adsbc/common/UnitConverter.kt new file mode 100644 index 0000000..aa620e7 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/UnitConverter.kt @@ -0,0 +1,14 @@ +package eu.darken.adsbc.common + +import android.content.Context +import android.util.TypedValue + +object UnitConverter { + fun dpToPx(context: Context, dp: Float): Int { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.resources.displayMetrics).toInt() + } + + fun spToPx(context: Context, sp: Float): Float { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, context.resources.displayMetrics) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/WebpageTool.kt b/app/src/main/java/eu/darken/adsbc/common/WebpageTool.kt new file mode 100644 index 0000000..94d6781 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/WebpageTool.kt @@ -0,0 +1,28 @@ +package eu.darken.adsbc.common + +import android.content.Context +import android.content.Intent +import android.net.Uri +import dagger.Reusable +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.adsbc.common.debug.logging.Logging.Priority.ERROR +import eu.darken.adsbc.common.debug.logging.log +import javax.inject.Inject + +@Reusable +class WebpageTool @Inject constructor( + @ApplicationContext private val context: Context, +) { + + fun open(address: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(address)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + } catch (e: Exception) { + log(ERROR) { "Failed to launch" } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/collections/MapExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/collections/MapExtensions.kt new file mode 100644 index 0000000..9f8a282 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/collections/MapExtensions.kt @@ -0,0 +1,5 @@ +package eu.darken.adsbc.common.collections + +inline fun Map.mutate(block: MutableMap.() -> Unit): Map { + return toMutableMap().apply(block).toMap() +} diff --git a/app/src/main/java/eu/darken/adsbc/common/compression/Zipper.kt b/app/src/main/java/eu/darken/adsbc/common/compression/Zipper.kt new file mode 100644 index 0000000..cee729e --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/compression/Zipper.kt @@ -0,0 +1,40 @@ +package eu.darken.adsbc.common.compression + +import eu.darken.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +// https://stackoverflow.com/a/48598099/1251958 +class Zipper { + + @Throws(Exception::class) + fun zip(files: Array, zipFile: String) { + + var origin: BufferedInputStream? + val out = ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))) + + for (i in files.indices) { + log(TAG, VERBOSE) { "Compressing ${files[i]} into $zipFile" } + origin = BufferedInputStream(FileInputStream(files[i]), BUFFER) + + val entry = ZipEntry(files[i].substring(files[i].lastIndexOf("/") + 1)) + out.putNextEntry(entry) + + origin.use { input -> input.copyTo(out) } + } + + out.finish() + out.close() + } + + companion object { + internal val TAG = logTag("Zipper") + const val BUFFER = 2048 + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/coroutine/AppCoroutineScope.kt b/app/src/main/java/eu/darken/adsbc/common/coroutine/AppCoroutineScope.kt new file mode 100644 index 0000000..608ec88 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/coroutine/AppCoroutineScope.kt @@ -0,0 +1,19 @@ +package eu.darken.adsbc.common.coroutine + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Inject +import javax.inject.Qualifier +import javax.inject.Singleton +import kotlin.coroutines.CoroutineContext + +@Singleton +class AppCoroutineScope @Inject constructor() : CoroutineScope { + override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default +} + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AppScope diff --git a/app/src/main/java/eu/darken/adsbc/common/coroutine/CoroutineModule.kt b/app/src/main/java/eu/darken/adsbc/common/coroutine/CoroutineModule.kt new file mode 100644 index 0000000..5d19b72 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/coroutine/CoroutineModule.kt @@ -0,0 +1,19 @@ +package eu.darken.adsbc.common.coroutine + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope + +@InstallIn(SingletonComponent::class) +@Module +abstract class CoroutineModule { + + @Binds + abstract fun dispatcherProvider(defaultProvider: DefaultDispatcherProvider): DispatcherProvider + + @Binds + @AppScope + abstract fun appscope(appCoroutineScope: AppCoroutineScope): CoroutineScope +} diff --git a/app/src/main/java/eu/darken/adsbc/common/coroutine/DefaultDispatcherProvider.kt b/app/src/main/java/eu/darken/adsbc/common/coroutine/DefaultDispatcherProvider.kt new file mode 100644 index 0000000..c382fa3 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/coroutine/DefaultDispatcherProvider.kt @@ -0,0 +1,7 @@ +package eu.darken.adsbc.common.coroutine + +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider diff --git a/app/src/main/java/eu/darken/adsbc/common/coroutine/DispatcherProvider.kt b/app/src/main/java/eu/darken/adsbc/common/coroutine/DispatcherProvider.kt new file mode 100644 index 0000000..5f52b83 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/coroutine/DispatcherProvider.kt @@ -0,0 +1,21 @@ +package eu.darken.adsbc.common.coroutine + +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +// Need this to improve testing +// Can currently only replace the main-thread dispatcher. +// https://github.com/Kotlin/kotlinx.coroutines/issues/1365 +@Suppress("PropertyName", "VariableNaming") +interface DispatcherProvider { + val Default: CoroutineContext + get() = Dispatchers.Default + val Main: CoroutineContext + get() = Dispatchers.Main + val MainImmediate: CoroutineContext + get() = Dispatchers.Main.immediate + val Unconfined: CoroutineContext + get() = Dispatchers.Unconfined + val IO: CoroutineContext + get() = Dispatchers.IO +} diff --git a/app/src/main/java/eu/darken/adsbc/common/dagger/AndroidModule.kt b/app/src/main/java/eu/darken/adsbc/common/dagger/AndroidModule.kt new file mode 100644 index 0000000..b0297c5 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/dagger/AndroidModule.kt @@ -0,0 +1,24 @@ +package eu.darken.adsbc.common.dagger + +import android.app.Application +import android.app.NotificationManager +import android.content.Context +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 +} diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/Bugs.kt b/app/src/main/java/eu/darken/adsbc/common/debug/Bugs.kt new file mode 100644 index 0000000..9341914 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/Bugs.kt @@ -0,0 +1,21 @@ +package eu.darken.adsbc.common.debug + +import com.bugsnag.android.Bugsnag +import eu.darken.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.Logging.Priority.WARN +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.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("Debug", "Bugs") +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/AutoReporting.kt b/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/AutoReporting.kt new file mode 100644 index 0000000..ccb58e6 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/AutoReporting.kt @@ -0,0 +1,58 @@ +package eu.darken.adsbc.common.debug.autoreport + +import android.content.Context +import com.bugsnag.android.Bugsnag +import com.bugsnag.android.Configuration +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.adsbc.common.InstallId +import eu.darken.adsbc.common.debug.Bugs +import eu.darken.adsbc.common.debug.autoreport.bugsnag.BugsnagErrorHandler +import eu.darken.adsbc.common.debug.autoreport.bugsnag.BugsnagLogger +import eu.darken.adsbc.common.debug.autoreport.bugsnag.NOPBugsnagErrorHandler +import eu.darken.adsbc.common.debug.logging.Logging +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class AutoReporting @Inject constructor( + @ApplicationContext private val context: Context, + private val bugReportSettings: DebugSettings, + private val installId: InstallId, + private val bugsnagLogger: Provider, + private val bugsnagErrorHandler: Provider, + private val nopBugsnagErrorHandler: Provider, +) { + + fun setup() { + val isEnabled = bugReportSettings.isAutoReportingEnabled.value + log(TAG) { "setup(): isEnabled=$isEnabled" } + + try { + val bugsnagConfig = Configuration.load(context).apply { + if (bugReportSettings.isAutoReportingEnabled.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("Debug", "AutoReporting") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/DebugSettings.kt b/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/DebugSettings.kt new file mode 100644 index 0000000..e312af5 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/DebugSettings.kt @@ -0,0 +1,35 @@ +package eu.darken.adsbc.common.debug.autoreport + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.preferences.PreferenceStoreMapper +import eu.darken.adsbc.common.preferences.Settings +import eu.darken.adsbc.common.preferences.createFlowPreference +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DebugSettings @Inject constructor( + @ApplicationContext private val context: Context, +) : Settings() { + + override val preferences: SharedPreferences by lazy { + context.getSharedPreferences("debug_settings", Context.MODE_PRIVATE) + } + + val isAutoReportingEnabled = preferences.createFlowPreference("debug.bugreport.automatic.enabled", false) + + val isDebugModeEnabled = preferences.createFlowPreference("debug.mode.enabled", false) + + override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper( + isAutoReportingEnabled, + isDebugModeEnabled + ) + + companion object { + internal val TAG = logTag("General", "Settings") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/bugsnag/BugsnagErrorHandler.kt b/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/bugsnag/BugsnagErrorHandler.kt new file mode 100644 index 0000000..bc35631 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/bugsnag/BugsnagErrorHandler.kt @@ -0,0 +1,58 @@ +package eu.darken.adsbc.common.debug.autoreport.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.adsbc.BuildConfig +import eu.darken.adsbc.common.debug.autoreport.DebugSettings +import eu.darken.adsbc.common.debug.logging.Logging.Priority.WARN +import eu.darken.adsbc.common.debug.logging.asLog +import eu.darken.adsbc.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, + private val debugSettings: DebugSettings, +) : 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 debugSettings.isAutoReportingEnabled.value && !BuildConfig.DEBUG + } + + companion object { + private const val TAB_APP = "app" + + @Suppress("DEPRECATION") + @SuppressLint("PackageManagerGetSignatures") + fun Context.tryFormattedSignature(): String? = try { + packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES).signatures?.let { sigs -> + val sb = StringBuilder("[") + for (i in sigs.indices) { + sb.append(sigs[i].hashCode()) + if (i + 1 != sigs.size) sb.append(", ") + } + sb.append("]") + sb.toString() + } + } catch (e: Exception) { + log(WARN) { e.asLog() } + null + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/bugsnag/BugsnagLogger.kt b/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/bugsnag/BugsnagLogger.kt new file mode 100644 index 0000000..d6639d9 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/bugsnag/BugsnagLogger.kt @@ -0,0 +1,47 @@ +package eu.darken.adsbc.common.debug.autoreport.bugsnag + +import com.bugsnag.android.Event +import eu.darken.adsbc.common.debug.logging.Logging +import eu.darken.adsbc.common.debug.logging.asLog +import java.lang.String.format +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BugsnagLogger @Inject constructor() : Logging.Logger { + + // Adding one to the initial size accounts for the add before remove. + private val buffer: Deque = ArrayDeque(BUFFER_SIZE + 1) + + override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map?) { + val line = "${System.currentTimeMillis()} ${priority.toLabel()}/$tag: $message" + synchronized(buffer) { + buffer.addLast(line) + if (buffer.size > BUFFER_SIZE) { + buffer.removeFirst() + } + } + } + + fun injectLog(event: Event) { + synchronized(buffer) { + var i = 100 + buffer.forEach { event.addMetadata("Log", format(Locale.ROOT, "%03d", i++), it) } + event.addMetadata("Log", format(Locale.ROOT, "%03d", i), event.originalError?.asLog()) + } + } + + companion object { + private const val BUFFER_SIZE = 200 + + private fun Logging.Priority.toLabel(): String = when (this) { + Logging.Priority.VERBOSE -> "V" + Logging.Priority.DEBUG -> "D" + Logging.Priority.INFO -> "I" + Logging.Priority.WARN -> "W" + Logging.Priority.ERROR -> "E" + Logging.Priority.ASSERT -> "WTF" + } + } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/bugsnag/NOPBugsnagErrorHandler.kt b/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/bugsnag/NOPBugsnagErrorHandler.kt new file mode 100644 index 0000000..4cd683d --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/autoreport/bugsnag/NOPBugsnagErrorHandler.kt @@ -0,0 +1,19 @@ +package eu.darken.adsbc.common.debug.autoreport.bugsnag + +import com.bugsnag.android.Event +import com.bugsnag.android.OnErrorCallback +import eu.darken.adsbc.common.debug.logging.Logging.Priority.WARN +import eu.darken.adsbc.common.debug.logging.asLog +import eu.darken.adsbc.common.debug.logging.log +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NOPBugsnagErrorHandler @Inject constructor() : OnErrorCallback { + + override fun onError(event: Event): Boolean { + log(WARN) { "Error, but skipping bugsnag due to user opt-out: ${event.originalError?.asLog()}" } + return false + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/logging/FileLogger.kt b/app/src/main/java/eu/darken/adsbc/common/debug/logging/FileLogger.kt new file mode 100644 index 0000000..0dd74a5 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/logging/FileLogger.kt @@ -0,0 +1,79 @@ +package eu.darken.adsbc.common.debug.logging + +import android.annotation.SuppressLint +import android.util.Log +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStreamWriter +import java.time.Instant + + +@SuppressLint("LogNotTimber") +class FileLogger(private val logFile: File) : Logging.Logger { + private var logWriter: OutputStreamWriter? = null + + @SuppressLint("SetWorldReadable") + @Synchronized + fun start() { + if (logWriter != null) return + + logFile.parentFile!!.mkdirs() + if (logFile.createNewFile()) { + Log.i(TAG, "File logger writing to " + logFile.path) + } + if (logFile.setReadable(true, false)) { + Log.i(TAG, "Debug run log read permission set") + } + + try { + logWriter = OutputStreamWriter(FileOutputStream(logFile, true)) + logWriter!!.write("=== BEGIN ===\n") + logWriter!!.write("Logfile: $logFile\n") + logWriter!!.flush() + Log.i(TAG, "File logger started.") + } catch (e: IOException) { + e.printStackTrace() + + logFile.delete() + if (logWriter != null) logWriter!!.close() + } + + } + + @Synchronized + fun stop() { + logWriter?.let { + logWriter = null + try { + it.write("=== END ===\n") + it.close() + } catch (ignore: IOException) { + } + Log.i(TAG, "File logger stopped.") + } + } + + override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map?) { + logWriter?.let { + try { + it.write("${Instant.ofEpochMilli(System.currentTimeMillis())} ${priority.shortLabel}/$tag: $message\n") + it.flush() + } catch (e: IOException) { + Log.e(TAG, "Failed to write log line.", e) + try { + it.close() + } catch (ignore: Exception) { + } + logWriter = null + } + } + } + + override fun toString(): String = "FileLogger(file=$logFile)" + + companion object { + private val TAG = logTag("Debug", "FileLogger") + } +} + diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/logging/LogCatLogger.kt b/app/src/main/java/eu/darken/adsbc/common/debug/logging/LogCatLogger.kt new file mode 100644 index 0000000..2b49aef --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/logging/LogCatLogger.kt @@ -0,0 +1,49 @@ +package eu.darken.adsbc.common.debug.logging + +import android.os.Build +import android.util.Log +import kotlin.math.min + +class LogCatLogger : Logging.Logger { + + override fun isLoggable(priority: Logging.Priority): Boolean = true + + override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map?) { + + val trimmedTag = if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) { + tag + } else { + tag.substring(0, MAX_TAG_LENGTH) + } + + if (message.length < MAX_LOG_LENGTH) { + writeToLogcat(priority.intValue, trimmedTag, message) + return + } + + // Split by line, then ensure each line can fit into Log's maximum length. + var i = 0 + val length = message.length + while (i < length) { + var newline = message.indexOf('\n', i) + newline = if (newline != -1) newline else length + do { + val end = min(newline, i + MAX_LOG_LENGTH) + val part = message.substring(i, end) + writeToLogcat(priority.intValue, trimmedTag, part) + i = end + } while (i < newline) + i++ + } + } + + private fun writeToLogcat(priority: Int, tag: String, part: String) = when (priority) { + Log.ASSERT -> Log.wtf(tag, part) + else -> Log.println(priority, tag, part) + } + + companion object { + private const val MAX_LOG_LENGTH = 4000 + private const val MAX_TAG_LENGTH = 23 + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/logging/LogExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/debug/logging/LogExtensions.kt new file mode 100644 index 0000000..0129143 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/logging/LogExtensions.kt @@ -0,0 +1,10 @@ +package eu.darken.adsbc.common.debug.logging + +fun logTag(vararg tags: String): String { + val sb = StringBuilder("ADSBC:") + for (i in tags.indices) { + sb.append(tags[i]) + if (i < tags.size - 1) sb.append(":") + } + return sb.toString() +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/logging/Logging.kt b/app/src/main/java/eu/darken/adsbc/common/debug/logging/Logging.kt new file mode 100644 index 0000000..b0342f8 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/logging/Logging.kt @@ -0,0 +1,132 @@ +package eu.darken.adsbc.common.debug.logging + +import java.io.PrintWriter +import java.io.StringWriter + +/** + * Inspired by + * https://github.com/PaulWoitaschek/Slimber + * https://github.com/square/logcat + * https://github.com/JakeWharton/timber + */ + +object Logging { + enum class Priority( + val intValue: Int, + val shortLabel: String + ) { + VERBOSE(2, "V"), + DEBUG(3, "D"), + INFO(4, "I"), + WARN(5, "W"), + ERROR(6, "E"), + ASSERT(7, "WTF"); + } + + interface Logger { + fun isLoggable(priority: Priority): Boolean = true + + fun log( + priority: Priority, + tag: String, + message: String, + metaData: Map? + ) + } + + private val internalLoggers = mutableListOf() + + val loggers: List + get() = synchronized(internalLoggers) { internalLoggers.toList() } + + val hasReceivers: Boolean + get() = synchronized(internalLoggers) { + internalLoggers.isNotEmpty() + } + + fun install(logger: Logger) { + synchronized(internalLoggers) { internalLoggers.add(logger) } + log { "Was installed $logger" } + } + + fun remove(logger: Logger) { + log { "Removing: $logger" } + synchronized(internalLoggers) { internalLoggers.remove(logger) } + } + + fun logInternal( + tag: String, + priority: Priority, + metaData: Map?, + message: String + ) { + val snapshot = synchronized(internalLoggers) { internalLoggers.toList() } + snapshot + .filter { it.isLoggable(priority) } + .forEach { + it.log( + priority = priority, + tag = tag, + metaData = metaData, + message = message + ) + } + } + + fun clearAll() { + log { "Clearing all loggers" } + synchronized(internalLoggers) { internalLoggers.clear() } + } +} + +inline fun Any.log( + priority: Logging.Priority = Logging.Priority.DEBUG, + metaData: Map? = null, + message: () -> String, +) { + if (Logging.hasReceivers) { + Logging.logInternal( + tag = logTag(logTagViaCallSite()), + priority = priority, + metaData = metaData, + message = message(), + ) + } +} + +inline fun log( + tag: String, + priority: Logging.Priority = Logging.Priority.DEBUG, + metaData: Map? = null, + message: () -> String, +) { + if (Logging.hasReceivers) { + Logging.logInternal( + tag = tag, + priority = priority, + metaData = metaData, + message = message(), + ) + } +} + +fun Throwable.asLog(): String { + val stringWriter = StringWriter(256) + val printWriter = PrintWriter(stringWriter, false) + printStackTrace(printWriter) + printWriter.flush() + return stringWriter.toString() +} + +@PublishedApi +internal fun Any.logTagViaCallSite(): String { + val javaClass = this::class.java + val fullClassName = javaClass.name + val outerClassName = fullClassName.substringBefore('$') + val simplerOuterClassName = outerClassName.substringAfterLast('.') + return if (simplerOuterClassName.isEmpty()) { + fullClassName + } else { + simplerOuterClassName.removeSuffix("Kt") + } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/recording/core/Recorder.kt b/app/src/main/java/eu/darken/adsbc/common/debug/recording/core/Recorder.kt new file mode 100644 index 0000000..15a4240 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/recording/core/Recorder.kt @@ -0,0 +1,48 @@ +package eu.darken.adsbc.common.debug.recording.core + +import eu.darken.adsbc.common.debug.logging.FileLogger +import eu.darken.adsbc.common.debug.logging.Logging +import eu.darken.adsbc.common.debug.logging.Logging.Priority.INFO +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import javax.inject.Inject + +class Recorder @Inject constructor() { + private val mutex = Mutex() + private var fileLogger: FileLogger? = null + + val isRecording: Boolean + get() = path != null + + var path: File? = null + private set + + suspend fun start(path: File) = mutex.withLock { + if (fileLogger != null) return@withLock + this.path = path + fileLogger = FileLogger(path) + fileLogger?.let { + it.start() + Logging.install(it) + log(TAG, INFO) { "Now logging to file!" } + } + } + + suspend fun stop() = mutex.withLock { + fileLogger?.let { + log(TAG, INFO) { "Stopping file-logger-tree: $it" } + Logging.remove(it) + it.stop() + fileLogger = null + this.path = null + } + } + + companion object { + internal val TAG = logTag("Debug", "Log", "Recorder") + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/recording/core/RecorderModule.kt b/app/src/main/java/eu/darken/adsbc/common/debug/recording/core/RecorderModule.kt new file mode 100644 index 0000000..4684f66 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/recording/core/RecorderModule.kt @@ -0,0 +1,123 @@ +package eu.darken.adsbc.common.debug.recording.core + +import android.content.Context +import android.content.Intent +import android.os.Environment +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.adsbc.common.BuildConfigWrap +import eu.darken.adsbc.common.coroutine.AppScope +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.Logging.Priority.ERROR +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.debug.recording.ui.RecorderActivity +import eu.darken.adsbc.common.flow.DynamicStateFlow +import eu.darken.adsbc.common.startServiceCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.plus +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RecorderModule @Inject constructor( + @ApplicationContext private val context: Context, + @AppScope private val appScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) { + + private val triggerFile = try { + File(context.getExternalFilesDir(null), FORCE_FILE) + } catch (e: Exception) { + File( + Environment.getExternalStorageDirectory(), + "/Android/data/${BuildConfigWrap.APPLICATION_ID}/files/$FORCE_FILE" + ) + } + + private val internalState = DynamicStateFlow(TAG, appScope + dispatcherProvider.IO) { + val triggerFileExists = triggerFile.exists() + State(shouldRecord = triggerFileExists) + } + val state: Flow = internalState.flow + + init { + internalState.flow + .onEach { + log(TAG) { "New Recorder state: $internalState" } + + internalState.updateBlocking { + if (!isRecording && shouldRecord) { + val newRecorder = Recorder() + newRecorder.start(createRecordingFilePath()) + triggerFile.createNewFile() + + context.startServiceCompat(Intent(context, RecorderService::class.java)) + + copy( + recorder = newRecorder + ) + } else if (!shouldRecord && isRecording) { + val currentLog = recorder!!.path!! + recorder.stop() + + if (triggerFile.exists() && !triggerFile.delete()) { + log(TAG, ERROR) { "Failed to delete trigger file" } + } + + val intent = RecorderActivity.getLaunchIntent(context, currentLog.path).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + copy( + recorder = null, + lastLogPath = currentLog + ) + } else { + this + } + } + } + .launchIn(appScope) + } + + private fun createRecordingFilePath() = File( + File(context.cacheDir, "debug/logs"), + "${BuildConfigWrap.APPLICATION_ID}_logfile_${System.currentTimeMillis()}.log" + ) + + suspend fun startRecorder(): File { + internalState.updateBlocking { + copy(shouldRecord = true) + } + return internalState.flow.filter { it.isRecording }.first().currentLogPath!! + } + + suspend fun stopRecorder(): File? { + val currentPath = internalState.value().currentLogPath ?: return null + internalState.updateBlocking { + copy(shouldRecord = false) + } + internalState.flow.filter { !it.isRecording }.first() + return currentPath + } + + data class State( + val shouldRecord: Boolean = false, + internal val recorder: Recorder? = null, + val lastLogPath: File? = null, + ) { + val isRecording: Boolean + get() = recorder != null + + val currentLogPath: File? + get() = recorder?.path + } + + companion object { + internal val TAG = logTag("Debug", "Log", "Recorder", "Module") + private const val FORCE_FILE = "force_debug_run" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/recording/core/RecorderService.kt b/app/src/main/java/eu/darken/adsbc/common/debug/recording/core/RecorderService.kt new file mode 100644 index 0000000..d9ef5c2 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/recording/core/RecorderService.kt @@ -0,0 +1,111 @@ +package eu.darken.adsbc.common.debug.recording.core + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.os.IBinder +import androidx.core.app.NotificationCompat +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.R +import eu.darken.adsbc.common.BuildConfigWrap +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.notifications.PendingIntentCompat +import eu.darken.adsbc.common.uix.Service2 +import eu.darken.adsbc.main.ui.MainActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + + +@AndroidEntryPoint +class RecorderService : Service2() { + private lateinit var builder: NotificationCompat.Builder + + @Inject lateinit var recorderModule: RecorderModule + @Inject lateinit var notificationManager: NotificationManager + @Inject lateinit var dispatcherProvider: DispatcherProvider + private val recorderScope by lazy { + CoroutineScope(SupervisorJob() + dispatcherProvider.IO) + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + NotificationChannel( + NOTIF_CHANID_DEBUG, + getString(R.string.debug_notification_channel_label), + NotificationManager.IMPORTANCE_MIN + ).run { notificationManager.createNotificationChannel(this) } + + val openIntent = Intent(this, MainActivity::class.java) + val openPi = PendingIntent.getActivity( + this, + 0, + openIntent, + PendingIntentCompat.FLAG_IMMUTABLE + ) + + val stopIntent = Intent(this, RecorderService::class.java) + stopIntent.action = STOP_ACTION + val stopPi = PendingIntent.getService( + this, + 0, + stopIntent, + PendingIntentCompat.FLAG_IMMUTABLE + ) + + builder = NotificationCompat.Builder(this, NOTIF_CHANID_DEBUG) + .setChannelId(NOTIF_CHANID_DEBUG) + .setContentIntent(openPi) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setSmallIcon(R.drawable.ic_baseline_bug_report_24) + .setContentText("Idle") + .setContentTitle(getString(R.string.app_name)) + .addAction(NotificationCompat.Action.Builder(0, getString(R.string.general_done_action), stopPi).build()) + + startForeground(NOTIFICATION_ID, builder.build()) + + recorderModule.state + .onEach { + if (it.isRecording) { + builder.setContentText("Recording debug log: ${it.currentLogPath?.path}") + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } else { + stopForeground(true) + stopSelf() + } + } + .launchIn(recorderScope) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + log(TAG) { "onStartCommand(intent=$intent, flags=$flags, startId=$startId" } + if (intent?.action == STOP_ACTION) { + recorderScope.launch { + recorderModule.stopRecorder() + } + } + return START_STICKY + } + + override fun onDestroy() { + recorderScope.coroutineContext.cancel() + super.onDestroy() + } + + companion object { + private val TAG = + logTag("Debug", "Log", "Recorder", "Service") + private val NOTIF_CHANID_DEBUG = "${BuildConfigWrap.APPLICATION_ID}.notification.channel.debug" + private const val STOP_ACTION = "STOP_SERVICE" + private const val NOTIFICATION_ID = 53 + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/recording/ui/RecorderActivity.kt b/app/src/main/java/eu/darken/adsbc/common/debug/recording/ui/RecorderActivity.kt new file mode 100644 index 0000000..0ddfaa4 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/recording/ui/RecorderActivity.kt @@ -0,0 +1,59 @@ +package eu.darken.adsbc.common.debug.recording.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.format.Formatter +import androidx.activity.viewModels +import androidx.core.view.isInvisible +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.error.asErrorDialogBuilder +import eu.darken.adsbc.common.uix.Activity2 +import eu.darken.adsbc.databinding.DebugRecordingActivityBinding + +@AndroidEntryPoint +class RecorderActivity : Activity2() { + + private lateinit var ui: DebugRecordingActivityBinding + private val vm: RecorderActivityVM by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + ui = DebugRecordingActivityBinding.inflate(layoutInflater) + setContentView(ui.root) + + vm.state.observe2 { state -> + ui.loadingIndicator.isInvisible = !state.loading + ui.share.isInvisible = state.loading + + ui.recordingPath.text = state.normalPath + + if (state.normalSize != -1L) { + ui.recordingSize.text = Formatter.formatShortFileSize(this, state.normalSize) + } + if (state.compressedSize != -1L) { + ui.recordingSizeCompressed.text = Formatter.formatShortFileSize(this, state.compressedSize) + } + } + + vm.errorEvents.observe2 { + it.asErrorDialogBuilder(this).show() + } + + ui.share.setOnClickListener { vm.share() } + vm.shareEvent.observe2 { startActivity(it) } + } + + companion object { + internal val TAG = logTag("Debug", "Log", "RecorderActivity") + const val RECORD_PATH = "logPath" + + fun getLaunchIntent(context: Context, path: String): Intent { + val intent = Intent(context, RecorderActivity::class.java) + intent.putExtra(RECORD_PATH, path) + return intent + } + } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/debug/recording/ui/RecorderActivityVM.kt b/app/src/main/java/eu/darken/adsbc/common/debug/recording/ui/RecorderActivityVM.kt new file mode 100644 index 0000000..9c425ba --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/debug/recording/ui/RecorderActivityVM.kt @@ -0,0 +1,116 @@ +package eu.darken.adsbc.common.debug.recording.ui + + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.adsbc.R +import eu.darken.adsbc.common.BuildConfigWrap +import eu.darken.adsbc.common.compression.Zipper +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.flow.DynamicStateFlow +import eu.darken.adsbc.common.flow.onError +import eu.darken.adsbc.common.flow.replayingShare +import eu.darken.adsbc.common.livedata.SingleLiveEvent +import eu.darken.adsbc.common.uix.ViewModel3 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.plus +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class RecorderActivityVM @Inject constructor( + handle: SavedStateHandle, + dispatcherProvider: DispatcherProvider, + @ApplicationContext private val context: Context, +) : ViewModel3(dispatcherProvider) { + + private val recordedPath = handle.get(RecorderActivity.RECORD_PATH)!! + private val pathCache = MutableStateFlow(recordedPath) + private val resultCacheObs = pathCache + .map { path -> Pair(path, File(path).length()) } + .replayingShare(vmScope) + + private val resultCacheCompressedObs = resultCacheObs + .map { uncompressed -> + val zipped = "${uncompressed.first}.zip" + Zipper().zip(arrayOf(uncompressed.first), zipped) + Pair(zipped, File(zipped).length()) + } + .replayingShare(vmScope + dispatcherProvider.IO) + + private val stater = DynamicStateFlow(TAG, vmScope) { State() } + val state = stater.asLiveData2() + + val shareEvent = SingleLiveEvent() + + init { + resultCacheObs + .onEach { (path, size) -> + stater.updateBlocking { copy(normalPath = path, normalSize = size) } + } + .launchInViewModel() + + resultCacheCompressedObs + .onEach { (path, size) -> + stater.updateBlocking { + copy( + compressedPath = path, + compressedSize = size, + loading = false + ) + } + } + .onError { errorEvents.postValue(it) } + .launchInViewModel() + + } + + fun share() = launch { + val (path, size) = resultCacheCompressedObs.first() + + val intent = Intent(Intent.ACTION_SEND).apply { + val uri = FileProvider.getUriForFile( + context, + BuildConfigWrap.APPLICATION_ID + ".provider", + File(path) + ) + + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + type = "application/zip" + + addCategory(Intent.CATEGORY_DEFAULT) + putExtra( + Intent.EXTRA_SUBJECT, + "${BuildConfigWrap.APPLICATION_ID} DebugLog - ${BuildConfigWrap.VERSION_DESCRIPTION})" + ) + putExtra(Intent.EXTRA_TEXT, "Your text here.") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + + val chooserIntent = Intent.createChooser(intent, context.getString(R.string.debug_debuglog_file_label)) + shareEvent.postValue(chooserIntent) + } + + data class State( + val normalPath: String? = null, + val normalSize: Long = -1L, + val compressedPath: String? = null, + val compressedSize: Long = -1L, + val loading: Boolean = true + ) + + companion object { + private val TAG = logTag("Debug", "Recorder", "VM") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/error/ErrorDialog.kt b/app/src/main/java/eu/darken/adsbc/common/error/ErrorDialog.kt new file mode 100644 index 0000000..0042822 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/error/ErrorDialog.kt @@ -0,0 +1,18 @@ +package eu.darken.adsbc.common.error + +import android.content.Context +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +fun Throwable.asErrorDialogBuilder( + context: Context +) = MaterialAlertDialogBuilder(context).apply { + val error = this@asErrorDialogBuilder + val localizedError = error.localized(context) + + setTitle(localizedError.label) + setMessage(localizedError.description) + + setPositiveButton(android.R.string.ok) { _, _ -> + + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/error/ErrorEventSource.kt b/app/src/main/java/eu/darken/adsbc/common/error/ErrorEventSource.kt new file mode 100644 index 0000000..dc379b2 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/error/ErrorEventSource.kt @@ -0,0 +1,7 @@ +package eu.darken.adsbc.common.error + +import eu.darken.adsbc.common.livedata.SingleLiveEvent + +interface ErrorEventSource { + val errorEvents: SingleLiveEvent +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/error/LocalizedError.kt b/app/src/main/java/eu/darken/adsbc/common/error/LocalizedError.kt new file mode 100644 index 0000000..8fab487 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/error/LocalizedError.kt @@ -0,0 +1,36 @@ +package eu.darken.adsbc.common.error + +import android.content.Context +import eu.darken.adsbc.R + +interface HasLocalizedError { + fun getLocalizedError(context: Context): LocalizedError +} + +data class LocalizedError( + val throwable: Throwable, + val label: String, + val description: String +) { + fun asText() = "$label:\n$description" +} + +fun Throwable.localized(c: Context): LocalizedError = when { + this is HasLocalizedError -> this.getLocalizedError(c) + localizedMessage != null -> LocalizedError( + throwable = this, + label = "${c.getString(R.string.general_error_label)}: ${this::class.simpleName!!}", + description = localizedMessage ?: getStackTracePeek() + ) + else -> LocalizedError( + throwable = this, + label = "${c.getString(R.string.general_error_label)}: ${this::class.simpleName!!}", + description = getStackTracePeek() + ) +} + +private fun Throwable.getStackTracePeek() = this.stackTraceToString() + .lines() + .filterIndexed { index, _ -> index > 1 } + .take(3) + .joinToString("\n") \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/error/ThrowableExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/error/ThrowableExtensions.kt new file mode 100644 index 0000000..2f8d5a9 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/error/ThrowableExtensions.kt @@ -0,0 +1,42 @@ +package eu.darken.adsbc.common.error + +import java.io.PrintWriter +import java.io.StringWriter +import java.lang.reflect.InvocationTargetException +import kotlin.reflect.KClass + +val Throwable.causes: Sequence + get() = sequence { + var subCause = cause + while (subCause != null) { + yield(subCause) + subCause = subCause.cause + } + } + +fun Throwable.getRootCause(): Throwable { + var error = this + while (error.cause != null) { + error = error.cause!! + } + if (error is InvocationTargetException) { + error = error.targetException + } + return error +} + +fun Throwable.hasCause(exceptionClazz: KClass): Boolean { + if (exceptionClazz.isInstance(this)) return true + return exceptionClazz.isInstance(this.getRootCause()) +} + +fun Throwable.getStackTraceString(): String { + val sw = StringWriter(256) + val pw = PrintWriter(sw, false) + printStackTrace(pw) + pw.flush() + return sw.toString() +} + +fun Throwable.tryUnwrap(kClass: KClass = RuntimeException::class): Throwable = + if (!kClass.isInstance(this)) this else cause ?: this \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/flow/DynamicStateFlow.kt b/app/src/main/java/eu/darken/adsbc/common/flow/DynamicStateFlow.kt new file mode 100644 index 0000000..f871d58 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/flow/DynamicStateFlow.kt @@ -0,0 +1,149 @@ +package eu.darken.adsbc.common.flow + +import eu.darken.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.asLog +import eu.darken.adsbc.common.debug.logging.log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.plus +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.CoroutineContext + +/** + * A thread safe stateful flow that can be updated blocking and async with a lazy initial value provider. + * + * @param loggingTag will be prepended to logging tag, i.e. "$loggingTag:HD" + * @param parentScope on which the update operations and callbacks will be executed on + * @param coroutineContext used in combination with [CoroutineScope] + * @param startValueProvider provides the first value, errors will be rethrown on [CoroutineScope] + */ +class DynamicStateFlow( + loggingTag: String? = null, + parentScope: CoroutineScope, + coroutineContext: CoroutineContext = parentScope.coroutineContext, + private val startValueProvider: suspend CoroutineScope.() -> T +) { + private val lTag = loggingTag?.let { "$it:DSFlow" } + + private val updateActions = MutableSharedFlow>( + replay = Int.MAX_VALUE, + extraBufferCapacity = Int.MAX_VALUE, + onBufferOverflow = BufferOverflow.SUSPEND + ) + private val valueGuard = Mutex() + + private val producer: Flow> = channelFlow { + var currentValue = valueGuard.withLock { + lTag?.let { log(it, VERBOSE) { "Providing startValue..." } } + + startValueProvider().also { startValue -> + val initializer = Update(onError = null, onModify = { startValue }) + send(State(value = startValue, updatedBy = initializer)) + lTag?.let { log(it, VERBOSE) { "...startValue provided and emitted." } } + } + } + + updateActions.collect { update -> + currentValue = valueGuard.withLock { + try { + update.onModify(currentValue).also { + send(State(value = it, updatedBy = update)) + } + } catch (e: Exception) { + lTag?.let { + log(it, VERBOSE) { "Data modifying failed (onError=${update.onError}): ${e.asLog()}" } + } + + if (update.onError != null) { + update.onError.invoke(e) + } else { + send(State(value = currentValue, error = e, updatedBy = update)) + } + + currentValue + } + } + } + + lTag?.let { log(it, VERBOSE) { "internal channelFlow finished." } } + } + + private val internalFlow = producer + .onStart { lTag?.let { log(it, VERBOSE) { "Internal onStart" } } } + .onCompletion { err -> + when { + err is CancellationException -> { + lTag?.let { log(it, VERBOSE) { "internal onCompletion() due to cancellation" } } + } + err != null -> { + lTag?.let { log(it, VERBOSE) { "internal onCompletion() due to error: ${err.asLog()}" } } + } + else -> { + lTag?.let { log(it, VERBOSE) { "internal onCompletion()" } } + } + } + } + .shareIn( + scope = parentScope + coroutineContext, + replay = 1, + started = SharingStarted.Lazily + ) + + val flow: Flow = internalFlow + .map { it.value } + .distinctUntilChanged() + + suspend fun value() = flow.first() + + /** + * Non blocking update method. + * Gets executed on the scope and context this instance was initialized with. + * + * @param onError if you don't provide this, and exception in [onUpdate] will the scope passed to this class + */ + fun updateAsync( + onError: (suspend (Exception) -> Unit) = { throw it }, + onUpdate: suspend T.() -> T, + ) { + val update: Update = Update( + onModify = onUpdate, + onError = onError + ) + runBlocking { updateActions.emit(update) } + } + + /** + * Blocking update method + * Gets executed on the scope and context this instance was initialized with. + * Waiting will happen on the callers scope. + * + * Any errors that occurred during [action] will be rethrown by this method. + */ + suspend fun updateBlocking(action: suspend T.() -> T): T { + val update: Update = Update(onModify = action) + updateActions.emit(update) + + lTag?.let { log(it, VERBOSE) { "Waiting for update." } } + val ourUpdate = internalFlow.first { it.updatedBy == update } + lTag?.let { log(it, VERBOSE) { "Finished waiting, got $ourUpdate" } } + + ourUpdate.error?.let { throw it } + + return ourUpdate.value + } + + private data class Update( + val onModify: suspend T.() -> T, + val onError: (suspend (Exception) -> Unit)? = null, + ) + + private data class State( + val value: T, + val error: Exception? = null, + val updatedBy: Update, + ) +} diff --git a/app/src/main/java/eu/darken/adsbc/common/flow/DynamicStateFlowExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/flow/DynamicStateFlowExtensions.kt new file mode 100644 index 0000000..c444d6b --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/flow/DynamicStateFlowExtensions.kt @@ -0,0 +1,3 @@ +package eu.darken.adsbc.common.flow + + diff --git a/app/src/main/java/eu/darken/adsbc/common/flow/FlowCombineExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/flow/FlowCombineExtensions.kt new file mode 100644 index 0000000..4538729 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/flow/FlowCombineExtensions.kt @@ -0,0 +1,213 @@ +package eu.darken.adsbc.common.flow + +import kotlinx.coroutines.flow.Flow + + +//@Suppress("UNCHECKED_CAST", "LongParameterList") +//inline fun combine( +// flow: Flow, +// flow2: Flow, +// crossinline transform: suspend (T1, T2) -> R +//): Flow = kotlinx.coroutines.flow.combine( +// flow, +// flow2 +//) { args: Array<*> -> +// transform( +// args[0] as T1, +// args[1] as T2 +// ) +//} + +@Suppress("UNCHECKED_CAST", "LongParameterList") +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + crossinline transform: suspend (T1, T2, T3) -> R +): Flow = kotlinx.coroutines.flow.combine( + flow, + flow2, + flow3, +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + ) +} + +@Suppress("UNCHECKED_CAST", "LongParameterList") +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5) -> R +): Flow = kotlinx.coroutines.flow.combine( + flow, + flow2, + flow3, + flow4, + flow5 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5 + ) +} + +@Suppress("UNCHECKED_CAST", "LongParameterList") +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R +): Flow = kotlinx.coroutines.flow.combine( + flow, + flow2, + flow3, + flow4, + flow5, + flow6 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6 + ) +} + +@Suppress("UNCHECKED_CAST", "LongParameterList") +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R +): Flow = kotlinx.coroutines.flow.combine( + flow, + flow2, + flow3, + flow4, + flow5, + flow6, + flow7 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7 + ) +} + +@Suppress("UNCHECKED_CAST", "LongParameterList") +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R +): Flow = kotlinx.coroutines.flow.combine( + flow, + flow2, + flow3, + flow4, + flow5, + flow6, + flow7, + flow8 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8 + ) +} + +@Suppress("UNCHECKED_CAST", "LongParameterList") +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + flow10: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10) -> R +): Flow = kotlinx.coroutines.flow.combine( + flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9, flow10 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + args[9] as T10 + ) +} + +@Suppress("UNCHECKED_CAST", "LongParameterList") +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + flow10: Flow, + flow11: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11) -> R +): Flow = kotlinx.coroutines.flow.combine( + flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9, flow10, flow11 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + args[9] as T10, + args[10] as T11 + ) +} diff --git a/app/src/main/java/eu/darken/adsbc/common/flow/FlowExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/flow/FlowExtensions.kt new file mode 100644 index 0000000..77a8997 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/flow/FlowExtensions.kt @@ -0,0 +1,74 @@ +package eu.darken.adsbc.common.flow + +import eu.darken.adsbc.common.debug.logging.Logging.Priority.ERROR +import eu.darken.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.asLog +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.error.hasCause +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlin.time.Duration + +/** + * Create a stateful flow, with the initial value of null, but never emits a null value. + * Helper method to create a new flow without suspending and without initial value + * The flow collector will just wait for the first value + */ +fun Flow.shareLatest( + tag: String? = null, + scope: CoroutineScope, + started: SharingStarted = SharingStarted.WhileSubscribed(replayExpirationMillis = 0) +) = this + .onStart { if (tag != null) log(tag) { "shareLatest(...) start" } } + .onEach { if (tag != null) log(tag) { "shareLatest(...) emission: $it" } } + .onCompletion { if (tag != null) log(tag) { "shareLatest(...) completed." } } + .catch { + if (tag != null) log(tag) { "shareLatest(...) catch(): ${it.asLog()}" } + throw it + } + .stateIn( + scope = scope, + started = started, + initialValue = null + ) + .filterNotNull() + +fun Flow.replayingShare(scope: CoroutineScope) = this.shareIn( + scope = scope, + replay = 1, + started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO) +) + +internal fun Flow.withPrevious(): Flow> = this + .scan(Pair(null, null)) { previous, current -> Pair(previous.second, current) } + .drop(1) + .map { + @Suppress("UNCHECKED_CAST") + it as Pair + } + + +fun Flow.onError(block: suspend (Throwable) -> Unit) = this.catch { + block(it) + throw it +} + +fun Flow.takeUntilAfter(predicate: suspend (T) -> Boolean) = transformWhile { + val fullfilled = predicate(it) + emit(it) + !fullfilled // We keep emitting until condition is fullfilled = true +} + +fun Flow.setupCommonEventHandlers(tag: String, identifier: () -> String) = this + .onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } } + .onEach { log(tag, VERBOSE) { "${identifier()}.onEach(): $it" } } + .onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } } + .catch { + if (it.hasCause(CancellationException::class)) { + log(tag, VERBOSE) { "${identifier()} cancelled" } + } else { + log(tag, ERROR) { "${identifier()} failed: ${it.asLog()}" } + throw it + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/http/HttpModule.kt b/app/src/main/java/eu/darken/adsbc/common/http/HttpModule.kt new file mode 100644 index 0000000..0473ea9 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/http/HttpModule.kt @@ -0,0 +1,48 @@ +package eu.darken.adsbc.common.http + +import com.squareup.moshi.Moshi +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import eu.darken.adsbc.common.debug.autoreport.DebugSettings +import eu.darken.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.log +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.converter.moshi.MoshiConverterFactory + +@Module +@InstallIn(SingletonComponent::class) +class HttpModule { + + @Reusable + @Provides + fun defaultHttpClient( + moshiConverterFactory: MoshiConverterFactory, + debugSettings: DebugSettings, + ): OkHttpClient { + val interceptors: List = listOf( + HttpLoggingInterceptor { message -> + log("HTTP", VERBOSE) { message } + }.apply { + if (debugSettings.isDebugModeEnabled.value) { + setLevel(HttpLoggingInterceptor.Level.BODY) + } else { + setLevel(HttpLoggingInterceptor.Level.BASIC) + } + }, + ) + + return OkHttpClient.Builder().apply { + interceptors.forEach { addInterceptor(it) } + }.build() + } + + @Reusable + @Provides + fun moshiConverter(moshi: Moshi): MoshiConverterFactory = MoshiConverterFactory.create(moshi) + +} diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/BaseAdapter.kt b/app/src/main/java/eu/darken/adsbc/common/lists/BaseAdapter.kt new file mode 100644 index 0000000..391a173 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/BaseAdapter.kt @@ -0,0 +1,58 @@ +package eu.darken.adsbc.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.adsbc.common.getColorForAttr + +abstract class BaseAdapter : RecyclerView.Adapter() { + + @CallSuper + final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T { + return onCreateBaseVH(parent, viewType) + } + + abstract fun onCreateBaseVH(parent: ViewGroup, viewType: Int): T + + @CallSuper + final override fun onBindViewHolder(holder: T, position: Int) { + onBindBaseVH(holder, position, mutableListOf()) + } + + @CallSuper + final override fun onBindViewHolder(holder: T, position: Int, payloads: MutableList) { + onBindBaseVH(holder, position, payloads) + } + + abstract fun onBindBaseVH(holder: T, position: Int, payloads: MutableList = mutableListOf()) + + abstract class VH(@LayoutRes layoutRes: Int, private val parent: ViewGroup) : RecyclerView.ViewHolder( + LayoutInflater.from(parent.context).inflate(layoutRes, parent, false) + ) { + + val context: Context + get() = parent.context + + val resources: Resources + get() = context.resources + + val layoutInflater: LayoutInflater + get() = LayoutInflater.from(context) + + fun getColor(@ColorRes colorRes: Int): Int = ContextCompat.getColor(context, colorRes) + + fun getColorForAttr(@AttrRes attrRes: Int): Int = context.getColorForAttr(attrRes) + + fun getString(@StringRes stringRes: Int, vararg args: Any): String = context.getString(stringRes, *args) + + fun getQuantityString(@PluralsRes pluralRes: Int, quantity: Int, vararg args: Any): String = + context.resources.getQuantityString(pluralRes, quantity, *args) + + fun getQuantityString(@PluralsRes pluralRes: Int, quantity: Int): String = + context.resources.getQuantityString(pluralRes, quantity, quantity) + } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/BindableVH.kt b/app/src/main/java/eu/darken/adsbc/common/lists/BindableVH.kt new file mode 100644 index 0000000..f9a378f --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/BindableVH.kt @@ -0,0 +1,26 @@ +package eu.darken.adsbc.common.lists + +import androidx.viewbinding.ViewBinding + +interface BindableVH { + + val viewBinding: Lazy + + val onBindData: ViewBindingT.(item: ItemT, payloads: List) -> Unit + + fun bind(item: ItemT, payloads: MutableList = mutableListOf()) = with(viewBinding.value) { + onBindData(item, payloads) + } +} + +@Suppress("unused") +inline fun BindableVH.binding( + payload: Boolean = true, + crossinline block: ViewBindingT.(ItemT) -> Unit, +): ViewBindingT.(item: ItemT, payloads: List) -> Unit = { item: ItemT, payloads: List -> + val newestItem = when (payload) { + true -> payloads.filterIsInstance().lastOrNull() ?: item + false -> item + } + block(newestItem) +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/DataAdapter.kt b/app/src/main/java/eu/darken/adsbc/common/lists/DataAdapter.kt new file mode 100644 index 0000000..1cfaf3c --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/DataAdapter.kt @@ -0,0 +1,13 @@ +package eu.darken.bb.common.lists + +import androidx.recyclerview.widget.RecyclerView + +interface DataAdapter { + val data: MutableList +} + +fun X.update(newData: List?, notify: Boolean = true) where X : DataAdapter, X : RecyclerView.Adapter<*> { + data.clear() + if (newData != null) data.addAll(newData) + if (notify) notifyDataSetChanged() +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/ItemSwipeTool.kt b/app/src/main/java/eu/darken/adsbc/common/lists/ItemSwipeTool.kt new file mode 100644 index 0000000..83ffcbd --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/ItemSwipeTool.kt @@ -0,0 +1,190 @@ +package eu.darken.adsbc.common.lists + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.Rect +import android.graphics.drawable.Drawable +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import eu.darken.adsbc.R +import eu.darken.adsbc.common.UnitConverter +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.getColorForAttr +import kotlin.math.absoluteValue + + +@Suppress("UnnecessaryVariable") +class ItemSwipeTool(vararg actions: SwipeAction) { + + init { + require(actions.isNotEmpty()) { + "SwipeTool without actions doesn't make sense." + } + require(actions.map { it.direction }.toSet().size == actions.size) { + "Duplicate direction actions are not allowed." + } + } + + private val touchCallback = object : ItemTouchHelper.SimpleCallback( + 0, + actions.map { it.direction } + .fold(initial = 0, operation = { acc: Int, dir: SwipeAction.Direction -> acc.or(dir.value) }) + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + return false + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, directionValue: Int) { + val action = actions.single { it.direction.value == directionValue } + log(TAG) { "onSwiped(): $action" } + action.callback(viewHolder, action.direction) + } + + override fun onChildDraw( + canvas: Canvas, + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + dX: Float, + dY: Float, + actionState: Int, + isCurrentlyActive: Boolean + ) { + super.onChildDraw(canvas, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + + val iv = viewHolder.itemView + val context = recyclerView.context + + val curDir: SwipeAction.Direction? = when { + dX > 0 -> SwipeAction.Direction.RIGHT + dX < 0 -> SwipeAction.Direction.LEFT + else -> null + } + + val background = actions.find { it.direction == curDir }?.background + when (curDir) { + SwipeAction.Direction.RIGHT -> { + background?.setBounds(iv.left, iv.top, iv.left + dX.toInt(), iv.bottom) + + } + SwipeAction.Direction.LEFT -> { + background?.setBounds(iv.right + dX.toInt(), iv.top, iv.right, iv.bottom) + } + else -> background?.setBounds(0, 0, 0, 0) + } + background?.draw(canvas) + + val defaultPadding = UnitConverter.dpToPx(recyclerView.context, 16f) + val textPaint = Paint() + textPaint.color = context.getColorForAttr(R.attr.colorOnError) + textPaint.textSize = UnitConverter.spToPx(context, 18f) + + val actionItem = actions.find { it.direction == curDir } + + when (curDir) { + SwipeAction.Direction.RIGHT -> { + val icon = actionItem?.icon + if (icon != null) { + val iconTop = iv.top + iv.height / 2 - icon.intrinsicHeight / 2 + val iconStart = defaultPadding + val iconEnd = iconStart + icon.intrinsicWidth + val iconBottom = iconTop + icon.intrinsicHeight + if (dX > iconEnd + defaultPadding) { + icon.bounds = Rect(iconStart, iconTop, iconEnd, iconBottom) + } else { + icon.bounds = Rect(0, 0, 0, 0) + } + } + + val label = actionItem?.label + if (label != null) { + val clipBounds = Rect() + canvas.getClipBounds(clipBounds) + textPaint.getTextBounds(label, 0, label.length, clipBounds) + + val textTop = iv.top + iv.height / 2 + (clipBounds.height() / 2) + var textStart = defaultPadding + if (icon != null) textStart += icon.intrinsicWidth + defaultPadding + val textEnd = textStart + clipBounds.width() + if (dX > textEnd) { + canvas.drawText(label, textStart.toFloat(), textTop.toFloat(), textPaint) + } + } + actions.filter { it.icon != icon }.forEach { + it.icon?.setBounds(0, 0, 0, 0) + } + } + SwipeAction.Direction.LEFT -> { + val icon = actions.find { it.direction == curDir }?.icon + if (icon != null) { + val iconTop = iv.top + iv.height / 2 - icon.intrinsicHeight / 2 + val iconStart = iv.width - defaultPadding - icon.intrinsicWidth + val iconEnd = iconStart + icon.intrinsicWidth + val iconBottom = iconTop + icon.intrinsicHeight + if (iv.width - dX.absoluteValue < iconStart - defaultPadding) { + icon.bounds = Rect(iconStart, iconTop, iconEnd, iconBottom) + } else { + icon.bounds = Rect(0, 0, 0, 0) + } + } + val label = actionItem?.label + if (label != null) { + val clipBounds = Rect() + canvas.getClipBounds(clipBounds) + textPaint.getTextBounds(label, 0, label.length, clipBounds) + + val textTop = iv.top + iv.height / 2 + (clipBounds.height() / 2) + var textStart = iv.width - defaultPadding - clipBounds.width() + if (icon != null) textStart = textStart - icon.intrinsicWidth - defaultPadding + val textEnd = iv.width - textStart + if (dX.absoluteValue > textEnd) { + canvas.drawText(label, textStart.toFloat(), textTop.toFloat(), textPaint) + } + } + actions.filter { it.icon != icon }.forEach { + it.icon?.setBounds(0, 0, 0, 0) + } + } + else -> { // NONE + actions.forEach { + it.icon?.setBounds(0, 0, 0, 0) + } + } + } + actions.forEach { + it.icon?.setColorFilter(context.getColorForAttr(R.attr.colorOnError), PorterDuff.Mode.SRC_IN) + it.icon?.draw(canvas) + } + } + } + private val touchHelper by lazy { ItemTouchHelper(touchCallback) } + + fun attach(recyclerView: RecyclerView) { + touchHelper.attachToRecyclerView(recyclerView) + } + + data class SwipeAction( + val direction: Direction, + val callback: (RecyclerView.ViewHolder, Direction) -> Unit, + val icon: Drawable?, + val label: String?, + val background: Drawable? + ) { + enum class Direction(val value: Int) { + LEFT(ItemTouchHelper.LEFT), + RIGHT(ItemTouchHelper.RIGHT), + START(ItemTouchHelper.START), + END(ItemTouchHelper.END), + } + } + + companion object { + internal val TAG = logTag("ItemSwipeTool") + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/ListItem.kt b/app/src/main/java/eu/darken/adsbc/common/lists/ListItem.kt new file mode 100644 index 0000000..c8c4b20 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/ListItem.kt @@ -0,0 +1,3 @@ +package eu.darken.bb.common.lists + +interface ListItem \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/RecyclerViewExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/lists/RecyclerViewExtensions.kt new file mode 100644 index 0000000..798c7b3 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/RecyclerViewExtensions.kt @@ -0,0 +1,13 @@ +package eu.darken.bb.common.lists + +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView + +fun RecyclerView.setupDefaults(adapter: RecyclerView.Adapter<*>? = null, dividers: Boolean = true) = apply { + layoutManager = LinearLayoutManager(context) + itemAnimator = DefaultItemAnimator() + if (dividers) addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + if (adapter != null) this.adapter = adapter +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/differ/AsyncDiffer.kt b/app/src/main/java/eu/darken/adsbc/common/lists/differ/AsyncDiffer.kt new file mode 100644 index 0000000..8dda1b7 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/differ/AsyncDiffer.kt @@ -0,0 +1,44 @@ +package eu.darken.bb.common.lists.differ + +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import eu.darken.adsbc.common.lists.differ.DifferItem +import eu.darken.bb.common.lists.modular.ModularAdapter +import eu.darken.bb.common.lists.modular.mods.StableIdMod + +class AsyncDiffer internal constructor( + adapter: A, + compareItem: (T, T) -> Boolean = { i1, i2 -> i1.stableId == i2.stableId }, + compareItemContent: (T, T) -> Boolean = { i1, i2 -> i1 == i2 }, + determinePayload: (T, T) -> Any? = { i1, i2 -> + when { + i1::class == i2::class -> i1.payloadProvider?.invoke(i1, i2) + else -> null + } + } +) where A : HasAsyncDiffer, A : ModularAdapter<*> { + private val callback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = compareItem(oldItem, newItem) + override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = compareItemContent(oldItem, newItem) + override fun getChangePayload(oldItem: T, newItem: T): Any? = determinePayload(oldItem, newItem) + } + + private val internalList = mutableListOf() + private val listDiffer = AsyncListDiffer(adapter, callback) + + val currentList: List + get() = synchronized(internalList) { internalList } + + init { + adapter.modules.add(0, StableIdMod(currentList)) + } + + fun submitUpdate(newData: List) { + listDiffer.submitList(newData) { + synchronized(internalList) { + internalList.clear() + internalList.addAll(newData) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/differ/AsyncDifferExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/lists/differ/AsyncDifferExtensions.kt new file mode 100644 index 0000000..ff35239 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/differ/AsyncDifferExtensions.kt @@ -0,0 +1,17 @@ +package eu.darken.adsbc.common.lists.differ + +import androidx.recyclerview.widget.RecyclerView +import eu.darken.bb.common.lists.differ.AsyncDiffer +import eu.darken.bb.common.lists.differ.HasAsyncDiffer +import eu.darken.bb.common.lists.modular.ModularAdapter + + +fun X.update(newData: List?) + where X : HasAsyncDiffer, X : RecyclerView.Adapter<*> { + + asyncDiffer.submitUpdate(newData ?: emptyList()) +} + +fun A.setupDiffer(): AsyncDiffer + where A : HasAsyncDiffer, A : ModularAdapter<*> = + AsyncDiffer(this) diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/differ/DifferItem.kt b/app/src/main/java/eu/darken/adsbc/common/lists/differ/DifferItem.kt new file mode 100644 index 0000000..b0b6f3f --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/differ/DifferItem.kt @@ -0,0 +1,10 @@ +package eu.darken.adsbc.common.lists.differ + +import eu.darken.bb.common.lists.ListItem + +interface DifferItem : ListItem { + val stableId: Long + + val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)? + get() = null +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/differ/HasAsyncDiffer.kt b/app/src/main/java/eu/darken/adsbc/common/lists/differ/HasAsyncDiffer.kt new file mode 100644 index 0000000..31bd508 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/differ/HasAsyncDiffer.kt @@ -0,0 +1,12 @@ +package eu.darken.bb.common.lists.differ + +import eu.darken.adsbc.common.lists.differ.DifferItem + +interface HasAsyncDiffer { + + val data: List + get() = asyncDiffer.currentList + + val asyncDiffer: AsyncDiffer<*, T> + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/modular/ModularAdapter.kt b/app/src/main/java/eu/darken/adsbc/common/lists/modular/ModularAdapter.kt new file mode 100644 index 0000000..f547379 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/modular/ModularAdapter.kt @@ -0,0 +1,95 @@ +package eu.darken.bb.common.lists.modular + +import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.annotation.LayoutRes +import androidx.recyclerview.widget.RecyclerView +import eu.darken.adsbc.common.lists.BaseAdapter + +abstract class ModularAdapter : BaseAdapter() { + val modules = mutableListOf() + + init { + modules.filterIsInstance().forEach { it.onAdapterReady(this) } + } + + override fun getItemId(position: Int): Long { + modules.filterIsInstance().forEach { + val id = it.getItemId(this, position) + if (id != null) return id + } + return super.getItemId(position) + } + + @CallSuper + override fun getItemViewType(position: Int): Int { + modules.filterIsInstance().forEach { + val type = it.onGetItemType(this, position) + if (type != null) return type + } + return super.getItemViewType(position) + } + + override fun onCreateBaseVH(parent: ViewGroup, viewType: Int): VH { + modules.filterIsInstance>().forEach { + val vh = it.onCreateModularVH(this, parent, viewType) + if (vh != null) return vh + } + throw IllegalStateException("Couldn't create VH for type $viewType with $parent") + } + + @CallSuper + override fun onBindBaseVH(holder: VH, position: Int, payloads: MutableList) { + modules.filterIsInstance>().forEach { + it.onBindModularVH(this, holder, position, payloads) + it.onPostBind(this, holder, position) + } + } + + @CallSuper + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + modules.filterIsInstance().forEach { it.onAttachedToRecyclerView(recyclerView) } + super.onAttachedToRecyclerView(recyclerView) + } + + @CallSuper + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + modules.filterIsInstance().forEach { it.onDetachedFromRecyclerView(recyclerView) } + super.onDetachedFromRecyclerView(recyclerView) + } + + abstract class VH(@LayoutRes layoutRes: Int, parent: ViewGroup) : BaseAdapter.VH(layoutRes, parent) + + interface Module { + interface Setup { + fun onAdapterReady(adapter: ModularAdapter<*>) + } + + interface Creator : Module { + fun onCreateModularVH(adapter: ModularAdapter, parent: ViewGroup, viewType: Int): T? + } + + interface Binder : Module { + fun onBindModularVH(adapter: ModularAdapter, vh: T, pos: Int, payloads: MutableList) { + // NOOP + } + + fun onPostBind(adapter: ModularAdapter, vh: T, pos: Int) { + // NOOP + } + } + + interface Typing : Module { + fun onGetItemType(adapter: ModularAdapter<*>, pos: Int): Int? + } + + interface ItemId : Module { + fun getItemId(adapter: ModularAdapter<*>, position: Int): Long? + } + + interface RecyclerViewLifecycle : Module { + fun onDetachedFromRecyclerView(recyclerView: RecyclerView) + fun onAttachedToRecyclerView(recyclerView: RecyclerView) + } + } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/ClickMod.kt b/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/ClickMod.kt new file mode 100644 index 0000000..e082a96 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/ClickMod.kt @@ -0,0 +1,12 @@ +package eu.darken.adsbc.common.lists.modular.mods + +import eu.darken.bb.common.lists.modular.ModularAdapter + +class ClickMod constructor( + private val listener: (VHT, Int) -> Unit +) : ModularAdapter.Module.Binder { + + override fun onBindModularVH(adapter: ModularAdapter, vh: VHT, pos: Int, payloads: MutableList) { + vh.itemView.setOnClickListener { listener.invoke(vh, pos) } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/DataBinderMod.kt b/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/DataBinderMod.kt new file mode 100644 index 0000000..bc585c3 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/DataBinderMod.kt @@ -0,0 +1,17 @@ +package eu.darken.adsbc.common.lists.modular.mods + +import androidx.viewbinding.ViewBinding +import eu.darken.adsbc.common.lists.BindableVH +import eu.darken.bb.common.lists.modular.ModularAdapter + +class DataBinderMod constructor( + private val data: List, + private val customBinder: ( + (adapter: ModularAdapter, vh: HolderT, pos: Int, payload: MutableList) -> Unit + )? = null +) : ModularAdapter.Module.Binder where HolderT : BindableVH, HolderT : ModularAdapter.VH { + + override fun onBindModularVH(adapter: ModularAdapter, vh: HolderT, pos: Int, payloads: MutableList) { + customBinder?.invoke(adapter, vh, pos, mutableListOf()) ?: vh.bind(data[pos], payloads) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/SimpleVHCreatorMod.kt b/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/SimpleVHCreatorMod.kt new file mode 100644 index 0000000..a906341 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/SimpleVHCreatorMod.kt @@ -0,0 +1,15 @@ +package eu.darken.bb.common.lists.modular.mods + +import android.view.ViewGroup +import eu.darken.bb.common.lists.modular.ModularAdapter + +class SimpleVHCreatorMod constructor( + private val viewType: Int = 0, + private val factory: (ViewGroup) -> HolderT +) : ModularAdapter.Module.Creator where HolderT : ModularAdapter.VH { + + override fun onCreateModularVH(adapter: ModularAdapter, parent: ViewGroup, viewType: Int): HolderT? { + if (this.viewType != viewType) return null + return factory.invoke(parent) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/StableIdMod.kt b/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/StableIdMod.kt new file mode 100644 index 0000000..3602af3 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/StableIdMod.kt @@ -0,0 +1,21 @@ +package eu.darken.bb.common.lists.modular.mods + +import androidx.recyclerview.widget.RecyclerView +import eu.darken.adsbc.common.lists.differ.DifferItem +import eu.darken.bb.common.lists.modular.ModularAdapter + +class StableIdMod constructor( + private val data: List, + private val customResolver: (position: Int) -> Long = { + (data[it] as? DifferItem)?.stableId ?: RecyclerView.NO_ID + } +) : ModularAdapter.Module.ItemId, ModularAdapter.Module.Setup { + + override fun onAdapterReady(adapter: ModularAdapter<*>) { + adapter.setHasStableIds(true) + } + + override fun getItemId(adapter: ModularAdapter<*>, position: Int): Long? { + return customResolver.invoke(position) + } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/TypedVHCreatorMod.kt b/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/TypedVHCreatorMod.kt new file mode 100644 index 0000000..a76c209 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/lists/modular/mods/TypedVHCreatorMod.kt @@ -0,0 +1,29 @@ +package eu.darken.bb.common.lists.modular.mods + +import android.view.ViewGroup +import eu.darken.bb.common.lists.modular.ModularAdapter + +class TypedVHCreatorMod constructor( + private val typeResolver: (Int) -> Boolean, + private val factory: (ViewGroup) -> HolderT +) : ModularAdapter.Module.Typing, + ModularAdapter.Module.Creator where HolderT : ModularAdapter.VH { + + private fun ModularAdapter<*>.determineOurViewType(): Int { + val typingModules = modules.filterIsInstance(ModularAdapter.Module.Typing::class.java) + return typingModules.indexOf(this@TypedVHCreatorMod) + } + + override fun onGetItemType(adapter: ModularAdapter<*>, pos: Int): Int? { + return if (typeResolver.invoke(pos)) adapter.determineOurViewType() else null + } + + override fun onCreateModularVH( + adapter: ModularAdapter, + parent: ViewGroup, + viewType: Int + ): HolderT? { + if (adapter.determineOurViewType() != viewType) return null + return factory.invoke(parent) + } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/livedata/SingleLiveEvent.kt b/app/src/main/java/eu/darken/adsbc/common/livedata/SingleLiveEvent.kt new file mode 100644 index 0000000..bd71ba3 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/livedata/SingleLiveEvent.kt @@ -0,0 +1,76 @@ +package eu.darken.adsbc.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.adsbc.common.debug.logging.Logging.Priority.WARN +import eu.darken.adsbc.common.debug.logging.log +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A lifecycle-aware observable that sends only new updates after subscription, used for events like + * navigation and Snackbar messages. + * + * + * This avoids a common problem with events: on configuration change (like rotation) an update + * can be emitted if the observer is active. This LiveData only calls the observable if there's an + * explicit call to setValue() or call(). + * + * + * Note that only one observer is going to be notified of changes. + * https://github.com/android/architecture-samples/blob/166ca3a93ad14c6e224a3ea9bfcbd773eb048fb0/todoapp/app/src/main/java/com/example/android/architecture/blueprints/todoapp/SingleLiveEvent.java + */ +class SingleLiveEvent : MutableLiveData() { + + private val pending = AtomicBoolean(false) + + @MainThread + override fun observe(owner: LifecycleOwner, observer: Observer) { + if (hasActiveObservers()) { + log(WARN) { "Multiple observers registered but only one will be notified of changes." } + } + + // Observe the internal MutableLiveData + super.observe( + owner, + { t -> + if (pending.compareAndSet(true, false)) { + observer.onChanged(t) + } + } + ) + } + + @MainThread + override fun setValue(@Nullable t: T?) { + pending.set(true) + super.setValue(t) + } + + /** + * Used for cases where T is Void, to make calls cleaner. + */ + @MainThread + fun call() { + value = null + } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/navigation/FragmentExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/navigation/FragmentExtensions.kt new file mode 100644 index 0000000..5b03c7f --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/navigation/FragmentExtensions.kt @@ -0,0 +1,38 @@ +package eu.darken.adsbc.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.adsbc.common.debug.logging.Logging.Priority.WARN +import eu.darken.adsbc.common.debug.logging.asLog +import eu.darken.adsbc.common.debug.logging.log + +fun Fragment.doNavigate(direction: NavDirections) = findNavController().doNavigate(direction) + +fun Fragment.popBackStack(): Boolean { + if (!isAdded) { + IllegalStateException("Fragment is not added").also { + log(WARN) { "Trying to pop backstack on Fragment that isn't added to an Activity: ${it.asLog()}" } + } + return false + } + return findNavController().popBackStack() +} + +/** + * [FragmentContainerView] does not access [NavController] in [Activity.onCreate] + * as workaround [FragmentManager] is used to get the [NavController] + * @param id [Int] NavFragment id + * @see issue-142847973 + */ +@Throws(IllegalStateException::class) +fun FragmentManager.findNavController(@IdRes id: Int): NavController { + val fragment = findFragmentById(id) ?: throw IllegalStateException("Fragment is not found for id:$id") + return NavHostFragment.findNavController(fragment) +} diff --git a/app/src/main/java/eu/darken/adsbc/common/navigation/NavArgsExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/navigation/NavArgsExtensions.kt new file mode 100644 index 0000000..7149b78 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/navigation/NavArgsExtensions.kt @@ -0,0 +1,20 @@ +package eu.darken.adsbc.common.navigation + +import android.os.Bundle +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavArgs +import androidx.navigation.NavArgsLazy +import java.io.Serializable + +// TODO Remove with "androidx.navigation:navigation-safe-args-gradle-plugin:2.4.0-alpha/stable" +inline fun SavedStateHandle.navArgs() = NavArgsLazy(Args::class) { + Bundle().apply { + keys().forEach { + when (val value = get(it)) { + is Serializable -> putSerializable(it, value) + is Parcelable -> putParcelable(it, value) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/navigation/NavControllerExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/navigation/NavControllerExtensions.kt new file mode 100644 index 0000000..d5de555 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/navigation/NavControllerExtensions.kt @@ -0,0 +1,22 @@ +package eu.darken.adsbc.common.navigation + +import android.os.Bundle +import androidx.annotation.IdRes +import androidx.navigation.NavController +import androidx.navigation.NavDirections + +fun NavController.navigateIfNotThere(@IdRes resId: Int, args: Bundle? = null) { + if (currentDestination?.id == resId) return + navigate(resId, args) +} + +fun NavController.doNavigate(direction: NavDirections) { + currentDestination?.getAction(direction.actionId)?.let { navigate(direction) } +} + +fun NavController.isGraphSet(): Boolean = try { + graph + true +} catch (e: IllegalStateException) { + false +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/navigation/NavDestinationExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/navigation/NavDestinationExtensions.kt new file mode 100644 index 0000000..f09551b --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/navigation/NavDestinationExtensions.kt @@ -0,0 +1,9 @@ +package eu.darken.adsbc.common.navigation + +import androidx.annotation.IdRes +import androidx.navigation.NavDestination + +fun NavDestination?.hasAction(@IdRes id: Int): Boolean { + if (this == null) return false + return getAction(id) != null +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/navigation/NavDirectionsExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/navigation/NavDirectionsExtensions.kt new file mode 100644 index 0000000..019bd3d --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/navigation/NavDirectionsExtensions.kt @@ -0,0 +1,13 @@ +package eu.darken.adsbc.common.navigation + +import androidx.lifecycle.MutableLiveData +import androidx.navigation.NavDirections +import eu.darken.adsbc.common.livedata.SingleLiveEvent + +fun NavDirections.navVia(pub: MutableLiveData) = pub.postValue(this) + +fun NavDirections.navVia(provider: NavEventSource) = this.navVia(provider.navEvents) + +interface NavEventSource { + val navEvents: SingleLiveEvent +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/notifications/PendingIntentCompat.kt b/app/src/main/java/eu/darken/adsbc/common/notifications/PendingIntentCompat.kt new file mode 100644 index 0000000..3ae64ac --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/notifications/PendingIntentCompat.kt @@ -0,0 +1,12 @@ +package eu.darken.adsbc.common.notifications + +import android.app.PendingIntent +import eu.darken.adsbc.common.hasApiLevel + +object PendingIntentCompat { + val FLAG_IMMUTABLE: Int = if (hasApiLevel(31)) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/preferences/FlowPreference.kt b/app/src/main/java/eu/darken/adsbc/common/preferences/FlowPreference.kt new file mode 100644 index 0000000..9f19e91 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/preferences/FlowPreference.kt @@ -0,0 +1,67 @@ +package eu.darken.adsbc.common.preferences + +import android.content.SharedPreferences +import androidx.core.content.edit +import eu.darken.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.log +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FlowPreference constructor( + private val preferences: SharedPreferences, + val key: String, + val rawReader: (Any?) -> T, + val rawWriter: (T) -> Any? +) { + + private val flowInternal = MutableStateFlow(value) + val flow: Flow = flowInternal + + private val preferenceChangeListener = + SharedPreferences.OnSharedPreferenceChangeListener { changedPrefs, changedKey -> + if (changedKey != key) return@OnSharedPreferenceChangeListener + + val newValue = rawReader(changedPrefs.all[key]) + + val currentValue = flowInternal.value + if (currentValue != newValue && flowInternal.compareAndSet(currentValue, newValue)) { + log(VERBOSE) { "$changedPrefs:$changedKey changed to $newValue" } + } + } + + init { + preferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + } + + var value: T + get() = rawReader(valueRaw) + set(newVal) { + valueRaw = rawWriter(newVal) + } + + var valueRaw: Any? + get() = preferences.all[key] ?: rawWriter(rawReader(null)) + set(value) { + preferences.edit { + 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() + } + + } + flowInternal.value = rawReader(value) + } + + fun update(update: (T) -> T) { + value = update(value) + } + + fun reset() { + valueRaw = null + } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/preferences/FlowPreferenceExtension.kt b/app/src/main/java/eu/darken/adsbc/common/preferences/FlowPreferenceExtension.kt new file mode 100644 index 0000000..d1ef239 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/preferences/FlowPreferenceExtension.kt @@ -0,0 +1,43 @@ +package eu.darken.adsbc.common.preferences + +import android.content.SharedPreferences + + +inline fun basicReader(defaultValue: T): (rawValue: Any?) -> T = + { rawValue -> + (rawValue ?: defaultValue) as T + } + +inline fun basicWriter(): (T) -> Any? = + { value -> + when (value) { + is Boolean -> value + is String -> value + is Int -> value + is Long -> value + is Float -> value + null -> null + else -> throw NotImplementedError() + } + } + +inline fun SharedPreferences.createFlowPreference( + key: String, + defaultValue: T = null as T +) = FlowPreference( + preferences = this, + key = key, + rawReader = basicReader(defaultValue), + rawWriter = basicWriter() +) + +inline fun SharedPreferences.createFlowPreference( + key: String, + noinline reader: (rawValue: Any?) -> T, + noinline writer: (value: T) -> Any? +) = FlowPreference( + preferences = this, + key = key, + rawReader = reader, + rawWriter = writer +) diff --git a/app/src/main/java/eu/darken/adsbc/common/preferences/FlowPreferenceMoshiExtension.kt b/app/src/main/java/eu/darken/adsbc/common/preferences/FlowPreferenceMoshiExtension.kt new file mode 100644 index 0000000..132fb86 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/preferences/FlowPreferenceMoshiExtension.kt @@ -0,0 +1,35 @@ +package eu.darken.adsbc.common.preferences + +import android.content.SharedPreferences +import com.squareup.moshi.Moshi + +inline fun moshiReader( + moshi: Moshi, + defaultValue: T, +): (Any?) -> T { + val adapter = moshi.adapter(T::class.java) + return { rawValue -> + rawValue as String? + rawValue?.let { adapter.fromJson(it) } ?: defaultValue + } +} + +inline fun moshiWriter( + moshi: Moshi, +): (T) -> Any? { + val adapter = moshi.adapter(T::class.java) + return { newValue: T -> + newValue?.let { adapter.toJson(it) } + } +} + +inline fun SharedPreferences.createFlowPreference( + key: String, + defaultValue: T = null as T, + moshi: Moshi, +) = FlowPreference( + preferences = this, + key = key, + rawReader = moshiReader(moshi, defaultValue), + rawWriter = moshiWriter(moshi) +) \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/preferences/PreferenceStoreMapper.kt b/app/src/main/java/eu/darken/adsbc/common/preferences/PreferenceStoreMapper.kt new file mode 100644 index 0000000..1199049 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/preferences/PreferenceStoreMapper.kt @@ -0,0 +1,76 @@ +package eu.darken.adsbc.common.preferences + +import androidx.preference.PreferenceDataStore + +open class PreferenceStoreMapper( + private vararg val flowPreferences: FlowPreference<*> +) : PreferenceDataStore() { + + override fun getBoolean(key: String, defValue: Boolean): Boolean { + return flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw as Boolean + } ?: throw NotImplementedError("getBoolean(key=$key, defValue=$defValue)") + } + + override fun putBoolean(key: String, value: Boolean) { + flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw = value + } ?: throw NotImplementedError("putBoolean(key=$key, defValue=$value)") + } + + override fun getString(key: String, defValue: String?): String? { + return flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw as String? + } ?: throw NotImplementedError("getString(key=$key, defValue=$defValue)") + } + + override fun putString(key: String, value: String?) { + flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw = value + } ?: throw NotImplementedError("putString(key=$key, defValue=$value)") + } + + override fun getInt(key: String?, defValue: Int): Int { + return flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw as Int + } ?: throw NotImplementedError("getInt(key=$key, defValue=$defValue)") + } + + override fun putInt(key: String?, value: Int) { + flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw = value + } ?: throw NotImplementedError("putInt(key=$key, defValue=$value)") + } + + override fun getLong(key: String?, defValue: Long): Long { + return flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw as Long + } ?: throw NotImplementedError("getLong(key=$key, defValue=$defValue)") + } + + override fun putLong(key: String?, value: Long) { + flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw = value + } ?: throw NotImplementedError("putLong(key=$key, defValue=$value)") + } + + override fun getFloat(key: String?, defValue: Float): Float { + return flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw as Float + } ?: throw NotImplementedError("getFloat(key=$key, defValue=$defValue)") + } + + override fun putFloat(key: String?, value: Float) { + flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw = value + } ?: throw NotImplementedError("putFloat(key=$key, defValue=$value)") + } + + override fun putStringSet(key: String?, values: MutableSet?) { + throw NotImplementedError("putStringSet(key=$key, defValue=$values)") + } + + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet { + throw NotImplementedError("getStringSet(key=$key, defValue=$defValues)") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/preferences/Settings.kt b/app/src/main/java/eu/darken/adsbc/common/preferences/Settings.kt new file mode 100644 index 0000000..ade0f4f --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/preferences/Settings.kt @@ -0,0 +1,12 @@ +package eu.darken.adsbc.common.preferences + +import android.content.SharedPreferences +import androidx.preference.PreferenceDataStore + +abstract class Settings { + + abstract val preferenceDataStore: PreferenceDataStore + + abstract val preferences: SharedPreferences + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/preferences/SharedPreferenceExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/preferences/SharedPreferenceExtensions.kt new file mode 100644 index 0000000..5ca2deb --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/preferences/SharedPreferenceExtensions.kt @@ -0,0 +1,16 @@ +package eu.darken.adsbc.common.preferences + +import android.content.SharedPreferences +import androidx.core.content.edit +import eu.darken.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.log + +fun SharedPreferences.clearAndNotify() { + val currentKeys = this.all.keys.toSet() + log(VERBOSE) { "$this clearAndNotify(): $currentKeys" } + edit { + currentKeys.forEach { remove(it) } + } + // Clear does not notify anyone using registerOnSharedPreferenceChangeListener + edit(commit = true) { clear() } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/room/CommonConverters.kt b/app/src/main/java/eu/darken/adsbc/common/room/CommonConverters.kt new file mode 100644 index 0000000..8ae1373 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/room/CommonConverters.kt @@ -0,0 +1,35 @@ +package eu.darken.adsbc.common.room + +import androidx.room.TypeConverter +import eu.darken.adsbc.feeder.core.ADSBxId +import eu.darken.adsbc.feeder.core.FeederId +import java.time.Instant +import java.util.* + +class CommonConverters { + + @TypeConverter + fun toUUID(value: String?): UUID? = value?.let { UUID.fromString(it) } + + @TypeConverter + fun fromUUID(uuid: UUID?): String? = uuid?.toString() + + @TypeConverter + fun toInstant(value: String?): Instant? = value?.let { Instant.parse(it) } + + @TypeConverter + fun fromInstant(date: Instant?): String? = date?.toString() + + @TypeConverter + fun toAdsbxId(value: String?): ADSBxId? = value?.let { ADSBxId(it) } + + @TypeConverter + fun fromAdsbxId(date: ADSBxId?): String? = date?.value + + @TypeConverter + fun toFeederId(value: String?): FeederId? = value?.let { FeederId(it) } + + @TypeConverter + fun fromFeederId(id: FeederId?): String? = id?.value + +} diff --git a/app/src/main/java/eu/darken/adsbc/common/serialiation/SerializationModule.kt b/app/src/main/java/eu/darken/adsbc/common/serialiation/SerializationModule.kt new file mode 100644 index 0000000..8b0fd65 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/serialiation/SerializationModule.kt @@ -0,0 +1,19 @@ +package eu.darken.adsbc.common.serialiation + +import com.squareup.moshi.Moshi +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + + +@Module +@InstallIn(SingletonComponent::class) +class SerializationModule { + + @Provides + @Singleton + fun moshi(): Moshi = Moshi.Builder() + .build() +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/ui/ConfirmableActionAdapterVH.kt b/app/src/main/java/eu/darken/adsbc/common/ui/ConfirmableActionAdapterVH.kt new file mode 100644 index 0000000..c75a613 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/ui/ConfirmableActionAdapterVH.kt @@ -0,0 +1,78 @@ +package eu.darken.adsbc.common.ui + +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.core.view.isGone +import eu.darken.adsbc.R +import eu.darken.adsbc.common.lists.BindableVH +import eu.darken.adsbc.common.lists.differ.DifferItem +import eu.darken.adsbc.databinding.ViewActionAdapterLineBinding +import eu.darken.bb.common.lists.modular.ModularAdapter + +abstract class ConfirmableActionAdapterVH(parent: ViewGroup) : + ModularAdapter.VH(R.layout.view_action_adapter_line, parent), + BindableVH, ViewActionAdapterLineBinding> { + + override val viewBinding = lazy { ViewActionAdapterLineBinding.bind(itemView) } + override val onBindData: ViewActionAdapterLineBinding.( + item: Confirmable, + payloads: List + ) -> Unit = { item, _ -> + val data = item.data + icon.setImageResource(getIcon(data)) + name.text = getLabel(data) + + val updateDescription = { + when { + item.currentLvl > 1 -> { + description.setText(R.string.general_confirmation_confirmation_label) + itemView.setBackgroundColor(getColorForAttr(R.attr.colorErrorContainer)) + } + item.currentLvl == 1 -> { + description.setText(R.string.general_confirmation_label) + itemView.setBackgroundColor(getColorForAttr(R.attr.colorErrorContainer)) + } + else -> { + description.text = getDesc(data) + itemView.setBackgroundColor(getColorForAttr(R.attr.backgroundColor)) + } + } + description.isGone = description.text.isNullOrEmpty() + } + updateDescription() + + + itemView.setOnClickListener { + item.guardedAction { item.onClick(it) } + updateDescription() + } + } + + @DrawableRes + abstract fun getIcon(item: T): Int + + abstract fun getLabel(item: T): String + + open fun getDesc(item: T): String? = null + +} + +data class Confirmable( + val data: T, + val requiredLvl: Int = 0, + var currentLvl: Int = 0, + val onClick: (T) -> Unit +) : DifferItem { + + fun guardedAction(action: (T) -> Unit) { + if (currentLvl >= requiredLvl || requiredLvl == 0) { + action(data) + currentLvl = 0 + } else { + currentLvl++ + } + } + + override val stableId: Long + get() = data.stableId +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/uix/Activity2.kt b/app/src/main/java/eu/darken/adsbc/common/uix/Activity2.kt new file mode 100644 index 0000000..de0c7f0 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/uix/Activity2.kt @@ -0,0 +1,44 @@ +package eu.darken.adsbc.common.uix + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.LiveData +import eu.darken.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag + +abstract class Activity2 : 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) + } + + fun LiveData.observe2(callback: (T) -> Unit) { + observe(this@Activity2) { callback.invoke(it) } + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/uix/BottomSheetDialogFragment2.kt b/app/src/main/java/eu/darken/adsbc/common/uix/BottomSheetDialogFragment2.kt new file mode 100644 index 0000000..d2d367f --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/uix/BottomSheetDialogFragment2.kt @@ -0,0 +1,95 @@ +package eu.darken.adsbc.common.uix + +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.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.error.asErrorDialogBuilder +import eu.darken.adsbc.common.navigation.doNavigate +import eu.darken.adsbc.common.navigation.popBackStack +import eu.darken.adsbc.common.observe2 + + +abstract class BottomSheetDialogFragment2 : BottomSheetDialogFragment() { + + abstract val ui: ViewBinding + abstract val vm: ViewModel3 + + 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) + + vm.navEvents.observe2(this, ui) { dir -> dir?.let { doNavigate(it) } ?: popBackStack() } + vm.errorEvents.observe2(this, ui) { it.asErrorDialogBuilder(requireContext()).show() } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + log(tag, VERBOSE) { "onActivityCreated(savedInstanceState=$savedInstanceState)" } + super.onActivityCreated(savedInstanceState) + } + + override fun onResume() { + log(tag, VERBOSE) { "onResume()" } + super.onResume() + } + + override fun onPause() { + log(tag, VERBOSE) { "onPause()" } + super.onPause() + } + + override fun onDestroyView() { + log(tag, VERBOSE) { "onDestroyView()" } + super.onDestroyView() + } + + override fun onDetach() { + log(tag, VERBOSE) { "onDetach()" } + super.onDetach() + } + + override fun onDestroy() { + log(tag, VERBOSE) { "onDestroy()" } + super.onDestroy() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + log(tag, VERBOSE) { "onActivityResult(requestCode=$requestCode, resultCode=$resultCode, data=$data)" } + super.onActivityResult(requestCode, resultCode, data) + } + + inline fun LiveData.observe2( + ui: VB, + crossinline callback: VB.(T) -> Unit + ) { + observe(viewLifecycleOwner) { callback.invoke(ui, it) } + } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/uix/Fragment2.kt b/app/src/main/java/eu/darken/adsbc/common/uix/Fragment2.kt new file mode 100644 index 0000000..e745217 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/uix/Fragment2.kt @@ -0,0 +1,80 @@ +package eu.darken.adsbc.common.uix + +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.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag + + +abstract class Fragment2(@LayoutRes val layoutRes: Int?) : Fragment(layoutRes ?: 0) { + + constructor() : this(null) + + internal val tag: String = + logTag("Fragment", "${this.javaClass.simpleName}(${Integer.toHexString(hashCode())})") + + override fun onAttach(context: Context) { + log(tag, VERBOSE) { "onAttach(context=$context)" } + super.onAttach(context) + } + + override fun onCreate(savedInstanceState: Bundle?) { + log(tag, VERBOSE) { "onCreate(savedInstanceState=$savedInstanceState)" } + super.onCreate(savedInstanceState) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + log(tag, VERBOSE) { + "onCreateView(inflater=$inflater, container=$container, savedInstanceState=$savedInstanceState" + } + return layoutRes?.let { inflater.inflate(it, container, false) } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + log(tag, VERBOSE) { "onViewCreated(view=$view, savedInstanceState=$savedInstanceState)" } + super.onViewCreated(view, savedInstanceState) + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + log(tag, VERBOSE) { "onActivityCreated(savedInstanceState=$savedInstanceState)" } + super.onActivityCreated(savedInstanceState) + } + + override fun onResume() { + log(tag, VERBOSE) { "onResume()" } + super.onResume() + } + + override fun onPause() { + log(tag, VERBOSE) { "onPause()" } + super.onPause() + } + + override fun onDestroyView() { + log(tag, VERBOSE) { "onDestroyView()" } + super.onDestroyView() + } + + override fun onDetach() { + log(tag, VERBOSE) { "onDetach()" } + super.onDetach() + } + + override fun onDestroy() { + log(tag, VERBOSE) { "onDestroy()" } + super.onDestroy() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + log(tag, VERBOSE) { "onActivityResult(requestCode=$requestCode, resultCode=$resultCode, data=$data)" } + super.onActivityResult(requestCode, resultCode, data) + } + +} diff --git a/app/src/main/java/eu/darken/adsbc/common/uix/Fragment3.kt b/app/src/main/java/eu/darken/adsbc/common/uix/Fragment3.kt new file mode 100644 index 0000000..9a043d0 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/uix/Fragment3.kt @@ -0,0 +1,53 @@ +package eu.darken.adsbc.common.uix + +import android.os.Bundle +import android.view.View +import androidx.annotation.LayoutRes +import androidx.lifecycle.LiveData +import androidx.viewbinding.ViewBinding +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.error.asErrorDialogBuilder +import eu.darken.adsbc.common.navigation.doNavigate +import eu.darken.adsbc.common.navigation.popBackStack + + +abstract class Fragment3(@LayoutRes layoutRes: Int?) : Fragment2(layoutRes) { + + constructor() : this(null) + + abstract val ui: ViewBinding? + abstract val vm: ViewModel3 + + var onErrorEvent: ((Throwable) -> Boolean)? = null + + var onFinishEvent: (() -> Unit)? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + vm.navEvents.observe2(ui) { + log { "navEvents: $it" } + + it?.run { doNavigate(this) } ?: onFinishEvent?.invoke() ?: popBackStack() + } + + vm.errorEvents.observe2(ui) { + val showDialog = onErrorEvent?.invoke(it) ?: true + if (showDialog) it.asErrorDialogBuilder(requireContext()).show() + } + } + + inline fun LiveData.observe2( + crossinline callback: (T) -> Unit + ) { + observe(viewLifecycleOwner) { callback.invoke(it) } + } + + inline fun LiveData.observe2( + ui: VB, + crossinline callback: VB.(T) -> Unit + ) { + observe(viewLifecycleOwner) { callback.invoke(ui, it) } + } + +} diff --git a/app/src/main/java/eu/darken/adsbc/common/uix/PreferenceFragment2.kt b/app/src/main/java/eu/darken/adsbc/common/uix/PreferenceFragment2.kt new file mode 100644 index 0000000..23a4d06 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/uix/PreferenceFragment2.kt @@ -0,0 +1,70 @@ +package eu.darken.adsbc.common.uix + +import android.content.SharedPreferences +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.annotation.MenuRes +import androidx.annotation.XmlRes +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceFragmentCompat +import eu.darken.adsbc.common.preferences.Settings +import eu.darken.adsbc.main.ui.settings.SettingsFragment + +abstract class PreferenceFragment2 + : PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener { + + abstract val settings: Settings + + @get:XmlRes + abstract val preferenceFile: Int + + val toolbar: Toolbar + get() = (parentFragment as SettingsFragment).toolbar + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + toolbar.menu.clear() + return super.onCreateView(inflater, container, savedInstanceState) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.preferenceDataStore = settings.preferenceDataStore + settings.preferences.registerOnSharedPreferenceChangeListener(this) + refreshPreferenceScreen() + } + + override fun onDestroy() { + settings.preferences.unregisterOnSharedPreferenceChangeListener(this) + super.onDestroy() + } + + override fun getCallbackFragment(): Fragment? = parentFragment + + fun refreshPreferenceScreen() { + if (preferenceScreen != null) preferenceScreen = null + addPreferencesFromResource(preferenceFile) + onPreferencesCreated() + } + + open fun onPreferencesCreated() { + + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { + + } + + fun setupMenu(@MenuRes menuResId: Int, block: (MenuItem) -> Unit) { + toolbar.apply { + menu.clear() + inflateMenu(menuResId) + setOnMenuItemClickListener { + block(it) + true + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/uix/Service2.kt b/app/src/main/java/eu/darken/adsbc/common/uix/Service2.kt new file mode 100644 index 0000000..191cdaa --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/uix/Service2.kt @@ -0,0 +1,52 @@ +package eu.darken.adsbc.common.uix + +import android.app.Service +import android.content.Intent +import android.content.res.Configuration +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag + +abstract class Service2 : Service() { + private val tag: String = + logTag("Service", this.javaClass.simpleName + "(" + Integer.toHexString(this.hashCode()) + ")") + + override fun onCreate() { + log(tag) { "onCreate()" } + super.onCreate() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + log(tag) { "onStartCommand(intent=$intent, flags=$flags startId=$startId)" } + return super.onStartCommand(intent, flags, startId) + } + + override fun onDestroy() { + log(tag) { "onDestroy()" } + super.onDestroy() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + log(tag) { "onConfigurationChanged(newConfig=$newConfig)" } + super.onConfigurationChanged(newConfig) + } + + override fun onLowMemory() { + log(tag) { "onLowMemory()" } + super.onLowMemory() + } + + override fun onUnbind(intent: Intent): Boolean { + log(tag) { "onUnbind(intent=$intent)" } + return super.onUnbind(intent) + } + + override fun onRebind(intent: Intent) { + log(tag) { "onRebind(intent=$intent)" } + super.onRebind(intent) + } + + override fun onTaskRemoved(rootIntent: Intent) { + log(tag) { "onTaskRemoved(rootIntent=$rootIntent)" } + super.onTaskRemoved(rootIntent) + } +} diff --git a/app/src/main/java/eu/darken/adsbc/common/uix/ViewModel1.kt b/app/src/main/java/eu/darken/adsbc/common/uix/ViewModel1.kt new file mode 100644 index 0000000..71ac772 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/uix/ViewModel1.kt @@ -0,0 +1,20 @@ +package eu.darken.adsbc.common.uix + +import androidx.annotation.CallSuper +import androidx.lifecycle.ViewModel +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag + +abstract class ViewModel1 : ViewModel() { + internal val tag: String = logTag("VM", javaClass.simpleName) + + init { + log(tag) { "Initialized" } + } + + @CallSuper + override fun onCleared() { + log(tag) { "onCleared()" } + super.onCleared() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/uix/ViewModel2.kt b/app/src/main/java/eu/darken/adsbc/common/uix/ViewModel2.kt new file mode 100644 index 0000000..4734b1d --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/uix/ViewModel2.kt @@ -0,0 +1,63 @@ +package eu.darken.adsbc.common.uix + +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import eu.darken.adsbc.common.coroutine.DefaultDispatcherProvider +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.Logging.Priority.WARN +import eu.darken.adsbc.common.debug.logging.asLog +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.error.ErrorEventSource +import eu.darken.adsbc.common.flow.DynamicStateFlow +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlin.coroutines.CoroutineContext + + +abstract class ViewModel2( + private val dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider(), +) : ViewModel1() { + + val vmScope = viewModelScope + dispatcherProvider.Default + + var launchErrorHandler: CoroutineExceptionHandler? = null + + private fun getVMContext(): CoroutineContext { + val dispatcher = dispatcherProvider.Default + return getErrorHandler()?.let { dispatcher + it } ?: dispatcher + } + + private fun getErrorHandler(): CoroutineExceptionHandler? { + val handler = launchErrorHandler + if (handler != null) return handler + + if (this is ErrorEventSource) { + return CoroutineExceptionHandler { _, ex -> + log(WARN) { "Error during launch: ${ex.asLog()}" } + errorEvents.postValue(ex) + } + } + + return null + } + + fun DynamicStateFlow.asLiveData2() = flow.asLiveData2() + + fun Flow.asLiveData2() = this.asLiveData(context = getVMContext()) + + fun launch( + scope: CoroutineScope = viewModelScope, + context: CoroutineContext = getVMContext(), + block: suspend CoroutineScope.() -> Unit + ) { + try { + scope.launch(context = context, block = block) + } catch (e: CancellationException) { + log(tag, WARN) { "launch()ed coroutine was canceled (scope=$scope): ${e.asLog()}" } + } + } + + open fun Flow.launchInViewModel() = this.launchIn(vmScope) + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/uix/ViewModel3.kt b/app/src/main/java/eu/darken/adsbc/common/uix/ViewModel3.kt new file mode 100644 index 0000000..49e6afe --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/uix/ViewModel3.kt @@ -0,0 +1,33 @@ +package eu.darken.adsbc.common.uix + +import androidx.navigation.NavDirections +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.asLog +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.error.ErrorEventSource +import eu.darken.adsbc.common.flow.setupCommonEventHandlers +import eu.darken.adsbc.common.livedata.SingleLiveEvent +import eu.darken.adsbc.common.navigation.NavEventSource +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn + + +abstract class ViewModel3( + dispatcherProvider: DispatcherProvider, +) : ViewModel2(dispatcherProvider), NavEventSource, ErrorEventSource { + + override val navEvents = SingleLiveEvent() + override val errorEvents = SingleLiveEvent() + + init { + launchErrorHandler = CoroutineExceptionHandler { _, ex -> + log(tag) { "Error during launch: ${ex.asLog()}" } + errorEvents.postValue(ex) + } + } + + override fun Flow.launchInViewModel() = this + .setupCommonEventHandlers(tag) { "launchInViewModel()" } + .launchIn(vmScope) +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/uix/ViewModelLazyKeyed.kt b/app/src/main/java/eu/darken/adsbc/common/uix/ViewModelLazyKeyed.kt new file mode 100644 index 0000000..3b1ffb0 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/uix/ViewModelLazyKeyed.kt @@ -0,0 +1,174 @@ +package eu.darken.adsbc.common.uix + +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import androidx.activity.ComponentActivity +import androidx.annotation.MainThread +import androidx.fragment.app.Fragment +import androidx.lifecycle.* +import kotlin.reflect.KClass + +/** + * Returns an existing ViewModel or creates a new one in the scope (usually, a fragment or + * an activity), associated with this `ViewModelProvider`. + * + * @see ViewModelProvider.get(Class) + */ +//@MainThread +//inline fun ViewModelProvider.get() = get(VM::class.java) + +/** + * An implementation of [Lazy] used by [androidx.fragment.app.Fragment.viewModels] and + * [androidx.activity.ComponentActivity.viewmodels]. + * + * [storeProducer] is a lambda that will be called during initialization, [VM] will be created + * in the scope of returned [ViewModelStore]. + * + * [factoryProducer] is a lambda that will be called during initialization, + * returned [ViewModelProvider.Factory] will be used for creation of [VM] + */ +class ViewModelLazyKeyed( + private val viewModelClass: KClass, + private val keyProducer: (() -> String)? = null, + private val storeProducer: () -> ViewModelStore, + private val factoryProducer: () -> ViewModelProvider.Factory +) : Lazy { + private var cached: VM? = null + + override val value: VM + get() { + val viewModel = cached + return if (viewModel == null) { + val factory = factoryProducer() + val store = storeProducer() + val key = keyProducer?.invoke() ?: "androidx.lifecycle.ViewModelProvider.DefaultKey" + ViewModelProvider(store, factory).get( + key + ":" + viewModelClass.java.canonicalName, + viewModelClass.java + ).also { + cached = it + } + } else { + viewModel + } + } + + override fun isInitialized() = cached != null +} + +/** + * Returns a property delegate to access [ViewModel] by **default** scoped to this [Fragment]: + * ``` + * class MyFragment : Fragment() { + * val viewmodel: NYViewModel by viewmodels() + * } + * ``` + * + * Custom [ViewModelProvider.Factory] can be defined via [factoryProducer] parameter, + * factory returned by it will be used to create [ViewModel]: + * ``` + * class MyFragment : Fragment() { + * val viewmodel: MYViewModel by viewmodels { myFactory } + * } + * ``` + * + * Default scope may be overridden with parameter [ownerProducer]: + * ``` + * class MyFragment : Fragment() { + * val viewmodel: MYViewModel by viewmodels ({requireParentFragment()}) + * } + * ``` + * + * This property can be accessed only after this Fragment is attached i.e., after + * [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException. + */ +@MainThread +inline fun Fragment.viewModelsKeyed( + noinline keyProducer: (() -> String)? = null, + noinline ownerProducer: () -> ViewModelStoreOwner = { this }, + noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null +) = createViewModelLazyKeyed(VM::class, keyProducer, { ownerProducer().viewModelStore }, factoryProducer) + +/** + * Returns a property delegate to access parent activity's [ViewModel], + * if [factoryProducer] is specified then [ViewModelProvider.Factory] + * returned by it will be used to create [ViewModel] first time. + * + * ``` + * class MyFragment : Fragment() { + * val viewmodel: MyViewModel by activityViewModels() + * } + * ``` + * + * This property can be accessed only after this Fragment is attached i.e., after + * [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException. + */ +@MainThread +inline fun Fragment.activityViewModelsKeyed( + noinline keyProducer: (() -> String)? = null, + noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null +) = createViewModelLazyKeyed(VM::class, keyProducer, { requireActivity().viewModelStore }, factoryProducer) + +/** + * Helper method for creation of [ViewModelLazy], that resolves `null` passed as [factoryProducer] + * to default factory. + */ +@MainThread +fun Fragment.createViewModelLazyKeyed( + viewModelClass: KClass, + keyProducer: (() -> String)? = null, + storeProducer: () -> ViewModelStore, + factoryProducer: (() -> ViewModelProvider.Factory)? = null +): Lazy { + val factoryPromise = factoryProducer ?: { + val application = activity?.application ?: throw IllegalStateException( + "ViewModel can be accessed only when Fragment is attached" + ) + ViewModelProvider.AndroidViewModelFactory.getInstance(application) + } + return ViewModelLazyKeyed(viewModelClass, keyProducer, storeProducer, factoryPromise) +} + +/** + * Returns a [Lazy] delegate to access the ComponentActivity's ViewModel, if [factoryProducer] + * is specified then [ViewModelProvider.Factory] returned by it will be used + * to create [ViewModel] first time. + * + * ``` + * class MyComponentActivity : ComponentActivity() { + * val viewmodel: MyViewModel by viewmodels() + * } + * ``` + * + * This property can be accessed only after the Activity is attached to the Application, + * and access prior to that will result in IllegalArgumentException. + */ +@MainThread +inline fun ComponentActivity.viewModelsKeyed( + noinline keyProducer: (() -> String)? = null, + noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null +): Lazy { + val factoryPromise = factoryProducer ?: { + val application = application ?: throw IllegalArgumentException( + "ViewModel can be accessed only when Activity is attached" + ) + ViewModelProvider.AndroidViewModelFactory.getInstance(application) + } + + return ViewModelLazyKeyed(VM::class, keyProducer, { viewModelStore }, factoryPromise) +} diff --git a/app/src/main/java/eu/darken/adsbc/common/viewbinding/ViewBindingExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/viewbinding/ViewBindingExtensions.kt new file mode 100644 index 0000000..cbf596e --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/viewbinding/ViewBindingExtensions.kt @@ -0,0 +1,94 @@ +package eu.darken.adsbc.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.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.Logging.Priority.WARN +import eu.darken.adsbc.common.debug.logging.log +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +inline fun FragmentT.viewBinding() = + this.viewBinding( + bindingProvider = { + val bindingMethod = BindingT::class.java.getMethod("bind", View::class.java) + bindingMethod(null, requireView()) as BindingT + }, + lifecycleOwnerProvider = { viewLifecycleOwner } + ) + +@Suppress("unused") +fun FragmentT.viewBinding( + bindingProvider: FragmentT.() -> BindingT, + lifecycleOwnerProvider: FragmentT.() -> LifecycleOwner +) = ViewBindingProperty(bindingProvider, lifecycleOwnerProvider) + +class ViewBindingProperty( + private val bindingProvider: (ComponentT) -> BindingT, + private val lifecycleOwnerProvider: ComponentT.() -> LifecycleOwner +) : ReadOnlyProperty { + + private val uiHandler = Handler(Looper.getMainLooper()) + private var localRef: ComponentT? = null + private var viewBinding: BindingT? = null + + private val onDestroyObserver = object : DefaultLifecycleObserver { + // Called right before Fragment.onDestroyView + override fun onDestroy(owner: LifecycleOwner) { + localRef?.lifecycle?.removeObserver(this) ?: return + + localRef = null + + uiHandler.post { + log(VERBOSE) { "Resetting viewBinding" } + viewBinding = null + } + } + } + + @MainThread + override fun getValue(thisRef: ComponentT, property: KProperty<*>): BindingT { + if (localRef == null && viewBinding != null) { + log(WARN) { "Fragment.onDestroyView() was called, but the handler didn't execute our delayed reset." } + /** + * There is a fragment racecondition if you navigate to another fragment and quickly popBackStack(). + * Our uiHandler.post { } will not have executed for some reason. + * In that case we manually null the old viewBinding, to allow for clean recreation. + */ + viewBinding = null + } + + /** + * When quickly navigating, a fragment may be created that was never visible to the user. + * It's possible that [Fragment.onDestroyView] is called, but [DefaultLifecycleObserver.onDestroy] is not. + * This means the ViewBinding will is not be set to `null` and it still holds the previous layout, + * instead of the new layout that the Fragment inflated when navigating back to it. + */ + (localRef as? Fragment)?.view?.let { + if (it != viewBinding?.root && localRef === thisRef) { + log(WARN) { "Different view for the same fragment, resetting old viewBinding." } + viewBinding = null + } + } + + viewBinding?.let { + // Only accessible from within the same component + require(localRef === thisRef) + return@getValue it + } + + val lifecycle = lifecycleOwnerProvider(thisRef).lifecycle + + return bindingProvider(thisRef).also { + viewBinding = it + localRef = thisRef + lifecycle.addObserver(onDestroyObserver) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/common/worker/WorkerExtensions.kt b/app/src/main/java/eu/darken/adsbc/common/worker/WorkerExtensions.kt new file mode 100644 index 0000000..a758f49 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/common/worker/WorkerExtensions.kt @@ -0,0 +1,31 @@ +package eu.darken.adsbc.common.worker + +import android.os.Parcel +import android.os.Parcelable +import androidx.work.Data + +@Suppress("UNCHECKED_CAST") +inline fun Data.getParcelable(key: String): T? { + val parcel = Parcel.obtain() + try { + val bytes = getByteArray(key) ?: return null + parcel.unmarshall(bytes, 0, bytes.size) + parcel.setDataPosition(0) + val creator = T::class.java.getField("CREATOR").get(null) as Parcelable.Creator + return creator.createFromParcel(parcel) + } finally { + parcel.recycle() + } +} + + +fun Data.Builder.putParcelable(key: String, parcelable: Parcelable): Data.Builder { + val parcel = Parcel.obtain() + try { + parcelable.writeToParcel(parcel, 0) + putByteArray(key, parcel.marshall()) + } finally { + parcel.recycle() + } + return this +} diff --git a/app/src/main/java/eu/darken/adsbc/enterprise/core/EnterpriseSettings.kt b/app/src/main/java/eu/darken/adsbc/enterprise/core/EnterpriseSettings.kt new file mode 100644 index 0000000..39ecda1 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/enterprise/core/EnterpriseSettings.kt @@ -0,0 +1,31 @@ +package eu.darken.adsbc.enterprise.core + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.preferences.PreferenceStoreMapper +import eu.darken.adsbc.common.preferences.Settings +import eu.darken.adsbc.common.preferences.createFlowPreference +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class EnterpriseSettings @Inject constructor( + @ApplicationContext private val context: Context +) : Settings() { + + override val preferences: SharedPreferences = + context.getSharedPreferences("settings_enterprise", Context.MODE_PRIVATE) + + val isEnterpriseMode = preferences.createFlowPreference("enterprise.enabled", false) + + override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper( + isEnterpriseMode + ) + + companion object { + internal val TAG = logTag("Enterprise", "Settings") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/ADSBxId.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/ADSBxId.kt new file mode 100644 index 0000000..ea9cf36 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/ADSBxId.kt @@ -0,0 +1,18 @@ +package eu.darken.adsbc.feeder.core + +import android.os.Parcelable +import androidx.annotation.Keep +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@Keep +@JsonClass(generateAdapter = true) +data class ADSBxId(val value: String) : Parcelable { + + init { + if (value.isEmpty()) throw IllegalArgumentException("ID can't be empty or blank.") + } + + override fun toString(): String = "FeederId($value)" +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/Feeder.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/Feeder.kt new file mode 100644 index 0000000..2313d43 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/Feeder.kt @@ -0,0 +1,25 @@ +package eu.darken.adsbc.feeder.core + +import eu.darken.adsbc.aircraft.core.Aircraft +import eu.darken.adsbc.feeder.core.storage.FeederData +import eu.darken.adsbc.stats.core.storage.FeederStatsData +import java.time.Instant + +data class Feeder( + val data: FeederData, + val stats: FeederStatsData, + val aircraft: Collection = emptySet(), + val lastError: Throwable? = null, +) { + val label: String + get() = data.label?.ifBlank { null } ?: adsbxId.value + + val lastSeenAt: Instant + get() = data.seenAt + + val uid: FeederId + get() = data.uid + + val adsbxId: ADSBxId + get() = data.adsbxId +} diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/FeederId.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/FeederId.kt new file mode 100644 index 0000000..37b11d7 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/FeederId.kt @@ -0,0 +1,10 @@ +package eu.darken.adsbc.feeder.core + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.* + +@Parcelize +data class FeederId( + val value: String = UUID.randomUUID().toString() +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/FeederRepo.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/FeederRepo.kt new file mode 100644 index 0000000..deb70a2 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/FeederRepo.kt @@ -0,0 +1,138 @@ +package eu.darken.adsbc.feeder.core + +import eu.darken.adsbc.aircraft.core.Aircraft +import eu.darken.adsbc.aircraft.core.AircraftStorage +import eu.darken.adsbc.common.collections.mutate +import eu.darken.adsbc.common.coroutine.AppScope +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.flow.DynamicStateFlow +import eu.darken.adsbc.common.flow.replayingShare +import eu.darken.adsbc.feeder.core.storage.FeederData +import eu.darken.adsbc.feeder.core.storage.FeederStorage +import eu.darken.adsbc.stats.core.api.ADSBxStatsApi +import eu.darken.adsbc.stats.core.storage.FeederStatsData +import eu.darken.adsbc.stats.core.storage.StatsStorage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.plus +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FeederRepo @Inject constructor( + @AppScope private val appScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val feederStorage: FeederStorage, + private val statsStorage: StatsStorage, + private val aircraftStorage: AircraftStorage, +) { + private val feederErrors = DynamicStateFlow>( + loggingTag = TAG, + parentScope = appScope + dispatcherProvider.IO + ) { emptyMap() } + + val allFeeders: Flow> = combine( + feederStorage.allFeeders, + feederErrors.flow, + aircraftStorage.allAircraft, + statsStorage.allFeederStats + ) { feeders, errorMap, aircraftMap, feederStats -> + + feeders.map { feederData -> + val feederId = feederData.uid + Feeder( + data = feederData, + stats = feederStats.singleOrNull { it.uid == feederId } ?: FeederStatsData(uid = feederId), + aircraft = aircraftMap[feederId] ?: emptySet(), + lastError = errorMap[feederId] + ) + } + }.replayingShare(appScope) + + val mergedAircrafts: Flow> = allFeeders.map { feeders -> + val aircraft = feeders.map { it.aircraft }.flatten().distinctBy { it.hexCode } + + aircraft.map { ac -> + SpottedAircraft( + aircraft = ac, + feeders = feeders + .filter { feeder -> + feeder.aircraft.any { it.hexCode == ac.hexCode } + } + .map { feeder -> + val specificAircraft = feeders + .single { it.uid == feeder.uid } + .aircraft.first { it.hexCode == ac.hexCode } + feeder to specificAircraft + } + ) + } + }.replayingShare(appScope) + + suspend fun update(feederId: FeederId, block: (FeederData) -> FeederData) { + log(TAG) { "update(feederId=$feederId, block=$block)" } + feederStorage.update(feederId, block) + } + + suspend fun updateAircraft(feederId: FeederId, aircraft: Collection) { + log(TAG) { "updateStats(feederId=$feederId, aircraft=${aircraft.size})" } + val start = System.currentTimeMillis() + + aircraftStorage.updateData(feederId, aircraft) + + feederStorage.update(feederId) { it.copy(seenAt = Instant.now()) } + + val duration = (System.currentTimeMillis() - start) + log(TAG, VERBOSE) { "Aircraft update took $duration ms" } + } + + suspend fun updateStats(feederId: FeederId, stats: ADSBxStatsApi.FeederInfo) { + log(TAG) { "updateStats(feederId=$feederId, stats=$stats)" } + val start = System.currentTimeMillis() + + statsStorage.update(feederId) { + it.copy( + statsUpdatedAt = Instant.now(), + aircraftCountLogged = stats.aircraftCountLogged + ) + } + feederStorage.update(feederId) { it.copy(seenAt = Instant.now()) } + + val duration = (System.currentTimeMillis() - start) + log(TAG, VERBOSE) { "Stats update took $duration ms" } + } + + suspend fun updateError(feederId: FeederId, e: Throwable) { + log(TAG) { "updateError(id=$feederId, error=$e)" } + feederErrors.updateBlocking { + mutate { this[feederId] = e } + } + } + + suspend fun delete(feederId: FeederId) { + log(TAG) { "delete(id=$feederId)" } + feederStorage.delete(feederId) + statsStorage.delete(feederId) + aircraftStorage.deleteByFeederId(feederId) + } + + suspend fun createFeeder(request: FeederSetupRequest): Feeder { + log(TAG) { "createFeeder(request=$request)" } + val newData = feederStorage.createFeeder(request) + statsStorage.create(newData.uid) + + return allFeeders + .map { fs -> fs.firstOrNull { it.uid == newData.uid } } + .filterNotNull() + .first() + .also { log(TAG) { "Feeder created: $it" } } + } + + companion object { + private val TAG = logTag("Feeder", "Repo") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/FeederRepoExtensions.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/FeederRepoExtensions.kt new file mode 100644 index 0000000..30a77a2 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/FeederRepoExtensions.kt @@ -0,0 +1,8 @@ +package eu.darken.adsbc.feeder.core + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +fun FeederRepo.feeder(id: FeederId): Flow = allFeeders.map { fs -> + fs.singleOrNull { it.uid == id } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/FeederSettings.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/FeederSettings.kt new file mode 100644 index 0000000..4a0ab9c --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/FeederSettings.kt @@ -0,0 +1,27 @@ +package eu.darken.adsbc.feeder.core + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.preferences.PreferenceStoreMapper +import eu.darken.adsbc.common.preferences.Settings +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FeederSettings @Inject constructor( + @ApplicationContext private val context: Context +) : Settings() { + + override val preferences: SharedPreferences = context.getSharedPreferences("settings_feeder", Context.MODE_PRIVATE) + + override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper( + + ) + + companion object { + internal val TAG = logTag("Feeder", "Settings") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/FeederSetupRequest.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/FeederSetupRequest.kt new file mode 100644 index 0000000..7e14896 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/FeederSetupRequest.kt @@ -0,0 +1,6 @@ +package eu.darken.adsbc.feeder.core + +data class FeederSetupRequest( + val adsbxId: ADSBxId, + val label: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/FeederUpdateService.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/FeederUpdateService.kt new file mode 100644 index 0000000..073bcce --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/FeederUpdateService.kt @@ -0,0 +1,83 @@ +package eu.darken.adsbc.feeder.core + +import eu.darken.adsbc.aircraft.core.api.ADSBxAircraftEndpoint +import eu.darken.adsbc.common.debug.logging.Logging.Priority.* +import eu.darken.adsbc.common.debug.logging.asLog +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.main.core.GeneralSettings +import eu.darken.adsbc.stats.core.api.ADSBxStatsEndpoint +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.isActive +import java.time.Duration +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FeederUpdateService @Inject constructor( + private val aircraftEndpointAdsbx: ADSBxAircraftEndpoint, + private val statsEndpointAdsbx: ADSBxStatsEndpoint, + private val generalSettings: GeneralSettings, + private val feederRepo: FeederRepo, +) { + + val autoUpdates = feederRepo.allFeeders + .distinctUntilChangedBy { feeders -> + feeders.map { it.adsbxId } + } + .flatMapLatest { ids -> + generalSettings.updateInterval.flow.map { it to ids } + } + .flatMapLatest { (updateInterval, feeders) -> + flow { + while (currentCoroutineContext().isActive) { + val updates = feeders.map { + try { + updateFeeder(it.uid, updateInterval) + } catch (e: Exception) { + log(TAG, WARN) { "Auto update failed for $it" } + } + } + emit(updates) + delay(updateInterval) + } + } + } + + + suspend fun updateFeeder(feederId: FeederId, updateInterval: Long = 0) = try { + log(TAG) { "updateFeeder(feederId=$feederId)" } + val updateStart = System.currentTimeMillis() + + val feeder = feederRepo.feeder(feederId).firstOrNull() + ?: throw IllegalArgumentException("Couldn't find $feederId") + + kotlin.run { + val aircraft = aircraftEndpointAdsbx.getAircraft(feeder.adsbxId) + feederRepo.updateAircraft(feederId, aircraft) + } + + kotlin.run { + val timeSinceLastStats = Duration.between(feeder.stats.statsUpdatedAt, Instant.now()) + + if (timeSinceLastStats.seconds < updateInterval * 6) return@run + + feederRepo.updateStats(feederId, statsEndpointAdsbx.getFeederStats(feeder.adsbxId)) + } + + val updateDuration = System.currentTimeMillis() - updateStart + log(TAG, VERBOSE) { "Update took ${updateDuration}ms for $feederId" } + Unit + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to update feeder info for feederId: ${e.asLog()}" } + feederRepo.updateError(feederId, e) + throw e + } + + companion object { + private val TAG = logTag("Feeder", "UpdateService") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/SpottedAircraft.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/SpottedAircraft.kt new file mode 100644 index 0000000..1aa73be --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/SpottedAircraft.kt @@ -0,0 +1,8 @@ +package eu.darken.adsbc.feeder.core + +import eu.darken.adsbc.aircraft.core.Aircraft + +data class SpottedAircraft( + val aircraft: Aircraft, + val feeders: Collection> +) diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/storage/FeederDao.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/storage/FeederDao.kt new file mode 100644 index 0000000..57a68c3 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/storage/FeederDao.kt @@ -0,0 +1,31 @@ +package eu.darken.adsbc.feeder.core.storage + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import eu.darken.adsbc.feeder.core.ADSBxId +import eu.darken.adsbc.feeder.core.FeederId +import kotlinx.coroutines.flow.Flow + +@Dao +interface FeederDao { + + @Query("SELECT * FROM feeders") + fun allFeeders(): Flow> + + @Query("SELECT * FROM feeders WHERE adsbxId IN (:ids)") + fun getFeederByAdsbxId(vararg ids: ADSBxId): FeederData? + + @Query("SELECT * FROM feeders WHERE uid IN (:ids)") + fun getFeederById(vararg ids: FeederId): FeederData? + + @Insert + suspend fun insertFeeder(feeder: FeederData) + + @Update(entity = FeederData::class) + suspend fun updateFeeder(feeder: FeederData) + + @Query("DELETE FROM feeders WHERE uid = :id") + suspend fun deleteFeeder(id: FeederId) +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/storage/FeederData.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/storage/FeederData.kt new file mode 100644 index 0000000..8275c47 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/storage/FeederData.kt @@ -0,0 +1,20 @@ +package eu.darken.adsbc.feeder.core.storage + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import eu.darken.adsbc.feeder.core.ADSBxId +import eu.darken.adsbc.feeder.core.FeederId +import java.time.Instant + +@Entity(tableName = "feeders") +data class FeederData( + @PrimaryKey val uid: FeederId = FeederId(), + @ColumnInfo(name = "addedAt") val addedAt: Instant = Instant.now(), + @ColumnInfo(name = "label") val label: String? = null, + @ColumnInfo(name = "comment") val comment: String? = null, + + @ColumnInfo(name = "adsbxId") val adsbxId: ADSBxId, + + @ColumnInfo(name = "seenAt") val seenAt: Instant = Instant.MIN, +) \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/storage/FeederDatabase.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/storage/FeederDatabase.kt new file mode 100644 index 0000000..aa52ef9 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/storage/FeederDatabase.kt @@ -0,0 +1,27 @@ +package eu.darken.adsbc.feeder.core.storage + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.adsbc.common.room.CommonConverters +import javax.inject.Inject + + +@Database( + entities = [FeederData::class], + version = 1 +) +@TypeConverters(CommonConverters::class) +abstract class FeederDatabase : RoomDatabase() { + + abstract fun feederDao(): FeederDao + + class Factory @Inject constructor(@ApplicationContext private val context: Context) { + fun create(): FeederDatabase = Room + .databaseBuilder(context, FeederDatabase::class.java, "feeder.db") + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/core/storage/FeederStorage.kt b/app/src/main/java/eu/darken/adsbc/feeder/core/storage/FeederStorage.kt new file mode 100644 index 0000000..3200d9a --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/core/storage/FeederStorage.kt @@ -0,0 +1,57 @@ +package eu.darken.adsbc.feeder.core.storage + +import androidx.room.withTransaction +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.feeder.core.FeederId +import eu.darken.adsbc.feeder.core.FeederSetupRequest +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FeederStorage @Inject constructor( + private val feederDatabaseFactory: FeederDatabase.Factory, +) { + + private val db by lazy { feederDatabaseFactory.create() } + private val feederDao by lazy { db.feederDao() } + + val allFeeders = feederDao.allFeeders() + + suspend fun createFeeder(request: FeederSetupRequest): FeederData { + log(TAG) { "createFeeder(request=$request)" } + + return db.withTransaction { + val existingFeeder = feederDao.getFeederByAdsbxId(request.adsbxId) + if (existingFeeder != null) throw IllegalStateException("${request.adsbxId} already exists") + + val entity = FeederData( + adsbxId = request.adsbxId, + label = request.label?.ifBlank { null } + ) + feederDao.insertFeeder(entity) + + feederDao.getFeederByAdsbxId(request.adsbxId)!! + } + } + + suspend fun delete(feederId: FeederId) { + log(TAG) { "delete(feederId=$feederId)" } + feederDao.deleteFeeder(feederId) + } + + suspend fun update(id: FeederId, block: (FeederData) -> FeederData): FeederData { + log(TAG) { "updateInfo(id=$id, block=$block)" } + return db.withTransaction { + val oldFeeder = feederDao.getFeederById(id) ?: throw IllegalArgumentException("Can't find $id") + val updatedFeeder = block(oldFeeder) + log(TAG) { "Updated feeder $id\nBefore:$oldFeeder\nNow: $oldFeeder" } + feederDao.updateFeeder(updatedFeeder) + updatedFeeder + } + } + + companion object { + private val TAG = logTag("Feeder", "Storage") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/ui/list/FeederAdapter.kt b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/FeederAdapter.kt new file mode 100644 index 0000000..0094e61 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/FeederAdapter.kt @@ -0,0 +1,75 @@ +package eu.darken.adsbc.feeder.ui.list + +import android.text.format.DateUtils +import android.view.ViewGroup +import eu.darken.adsbc.R +import eu.darken.adsbc.common.lists.BindableVH +import eu.darken.adsbc.common.lists.binding +import eu.darken.adsbc.common.lists.differ.DifferItem +import eu.darken.adsbc.common.lists.differ.setupDiffer +import eu.darken.adsbc.common.lists.modular.mods.DataBinderMod +import eu.darken.adsbc.databinding.FeederListLineBinding +import eu.darken.adsbc.feeder.core.Feeder +import eu.darken.bb.common.lists.differ.AsyncDiffer +import eu.darken.bb.common.lists.differ.HasAsyncDiffer +import eu.darken.bb.common.lists.modular.ModularAdapter +import eu.darken.bb.common.lists.modular.mods.SimpleVHCreatorMod +import java.time.Instant +import javax.inject.Inject + +class FeederAdapter @Inject constructor() : ModularAdapter(), + HasAsyncDiffer { + + override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer() + + override fun getItemCount(): Int = data.size + + init { + modules.add(DataBinderMod(data)) + modules.add(SimpleVHCreatorMod { FeederVH(it) }) + } + + data class Item( + val feeder: Feeder, + val onClickAction: (Feeder) -> Unit + ) : DifferItem { + override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?) = { old, new -> + if (new::class.isInstance(old)) new else null + } + + override val stableId: Long = feeder.adsbxId.hashCode().toLong() + } + + class FeederVH(parent: ViewGroup) : ModularAdapter.VH(R.layout.feeder_list_line, parent), + BindableVH { + + override val viewBinding: Lazy = lazy { FeederListLineBinding.bind(itemView) } + + override val onBindData: FeederListLineBinding.( + item: Item, + payloads: List + ) -> Unit = binding { item -> + val feeder = item.feeder + label.text = feeder.label + + lastSeen.text = if (feeder.lastSeenAt == Instant.MIN) { + getString(R.string.last_seen_not_seen_label) + } else { + val timeAgo = DateUtils.getRelativeTimeSpanString( + feeder.lastSeenAt.toEpochMilli(), + Instant.now().toEpochMilli(), + DateUtils.FORMAT_ABBREV_RELATIVE.toLong() + ) + "Seen $timeAgo" + } + + planesInfoLast.text = if (feeder.lastError != null) { + feeder.lastError.toString() + } else { + "Tracking ${feeder.aircraft.size} aircrafts. Aircraft count logged ${feeder.stats.aircraftCountLogged} times." + } + + itemView.setOnClickListener { item.onClickAction(feeder) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/ui/list/FeederListFragment.kt b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/FeederListFragment.kt new file mode 100644 index 0000000..356d627 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/FeederListFragment.kt @@ -0,0 +1,64 @@ +package eu.darken.adsbc.feeder.ui.list + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.R +import eu.darken.adsbc.common.WebpageTool +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.lists.differ.update +import eu.darken.adsbc.common.uix.Fragment3 +import eu.darken.adsbc.common.viewbinding.viewBinding +import eu.darken.adsbc.databinding.FeederListFragmentBinding +import eu.darken.adsbc.databinding.FeederListInputDialogBinding +import eu.darken.bb.common.lists.setupDefaults +import javax.inject.Inject + + +@AndroidEntryPoint +class FeederListFragment : Fragment3(R.layout.feeder_list_fragment) { + + override val vm: FeederListFragmentVM by viewModels() + override val ui: FeederListFragmentBinding by viewBinding() + + @Inject lateinit var webpageTool: WebpageTool + @Inject lateinit var feederAdapter: FeederAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + ui.list.setupDefaults(feederAdapter) + + ui.fab.setOnClickListener { + log(TAG) { "Launching add feeder dialog" } + + MaterialAlertDialogBuilder(requireContext()).apply { + val layout = FeederListInputDialogBinding.inflate(layoutInflater) + + setView(layout.root) + setTitle(R.string.add_feeder_title) + setMessage(R.string.add_feeder_description) + setPositiveButton(R.string.general_add_action) { _, _ -> + vm.addFeeder( + label = layout.feederLabelInputText.text.toString(), + adsbxId = layout.adsbxIdInputText.text.toString() + ) + } + setNegativeButton(R.string.general_cancel_action) { _, _ -> } + setNeutralButton(R.string.general_help_info) { _, _ -> + webpageTool.open("https://github.com/adsbxchange/adsbexchange-stats") + } + }.show() + } + + vm.feeders.observe2(ui) { + feederAdapter.update(it) + } + super.onViewCreated(view, savedInstanceState) + } + + companion object { + private val TAG = logTag("Feeder", "List", "Fragment") + } +} diff --git a/app/src/main/java/eu/darken/adsbc/feeder/ui/list/FeederListFragmentVM.kt b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/FeederListFragmentVM.kt new file mode 100644 index 0000000..5e26c97 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/FeederListFragmentVM.kt @@ -0,0 +1,64 @@ +package eu.darken.adsbc.feeder.ui.list + +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.Logging.Priority.ERROR +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.navigation.navVia +import eu.darken.adsbc.common.uix.ViewModel3 +import eu.darken.adsbc.feeder.core.* +import eu.darken.adsbc.main.ui.main.MainFragmentDirections +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive +import javax.inject.Inject +import kotlin.coroutines.coroutineContext + +@HiltViewModel +class FeederListFragmentVM @Inject constructor( + handle: SavedStateHandle, + dispatcherProvider: DispatcherProvider, + private val feederRepo: FeederRepo, + private val feederUpdateService: FeederUpdateService, +) : ViewModel3(dispatcherProvider = dispatcherProvider) { + + private val ticker = flow { + while (coroutineContext.isActive) { + emit(Unit) + delay(1000) + } + } + val feeders = combine( + feederRepo.allFeeders, + ticker + ) { feeders, _ -> + feeders.map { feeder -> + FeederAdapter.Item(feeder = feeder) { editFeeder(it.uid) } + } + }.asLiveData2() + + fun addFeeder(label: String, adsbxId: String) = launch { + log(TAG) { "addFeeder(label=$label, adsbxId=$adsbxId)" } + val request = FeederSetupRequest( + label = label, + adsbxId = ADSBxId(adsbxId) + ) + val createdFeeder = feederRepo.createFeeder(request) + try { + feederUpdateService.updateFeeder(createdFeeder.uid) + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to do initial feeder update." } + } + } + + fun editFeeder(id: FeederId) { + MainFragmentDirections.actionMainFragmentToFeederActionDialog(id).navVia(this) + } + + companion object { + private val TAG = logTag("Feeder", "List", "VM") + } +} diff --git a/app/src/main/java/eu/darken/adsbc/feeder/ui/list/actions/ActionsAdapter.kt b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/actions/ActionsAdapter.kt new file mode 100644 index 0000000..532370e --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/actions/ActionsAdapter.kt @@ -0,0 +1,32 @@ +package eu.darken.adsbc.feeder.ui.list.actions + +import android.view.ViewGroup +import eu.darken.adsbc.common.lists.differ.setupDiffer +import eu.darken.adsbc.common.lists.modular.mods.DataBinderMod +import eu.darken.adsbc.common.ui.Confirmable +import eu.darken.adsbc.common.ui.ConfirmableActionAdapterVH +import eu.darken.bb.common.lists.differ.AsyncDiffer +import eu.darken.bb.common.lists.differ.HasAsyncDiffer +import eu.darken.bb.common.lists.modular.ModularAdapter +import eu.darken.bb.common.lists.modular.mods.SimpleVHCreatorMod +import javax.inject.Inject + +class ActionsAdapter @Inject constructor() : + ModularAdapter(), + HasAsyncDiffer> { + + override val asyncDiffer: AsyncDiffer> = setupDiffer() + + override fun getItemCount(): Int = data.size + + init { + modules.add(DataBinderMod(data)) + modules.add(SimpleVHCreatorMod { VH(it) }) + } + + class VH(parent: ViewGroup) : ConfirmableActionAdapterVH(parent) { + override fun getIcon(item: FeederAction): Int = item.iconRes + + override fun getLabel(item: FeederAction): String = getString(item.labelRes) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/ui/list/actions/FeederAction.kt b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/actions/FeederAction.kt new file mode 100644 index 0000000..a66f6c5 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/actions/FeederAction.kt @@ -0,0 +1,15 @@ +package eu.darken.adsbc.feeder.ui.list.actions + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import eu.darken.adsbc.R +import eu.darken.adsbc.common.DialogActionEnum + +enum class FeederAction constructor( + @DrawableRes val iconRes: Int, + @StringRes val labelRes: Int +) : DialogActionEnum { + RENAME(R.drawable.ic_baseline_drive_file_rename_outline_24, R.string.feeder_rename_action), + REFRESH(R.drawable.ic_baseline_refresh_24, R.string.feeder_refresh_action), + DELETE(R.drawable.ic_baseline_delete_sweep_24, R.string.feeder_delete_action) +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/ui/list/actions/FeederActionDialog.kt b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/actions/FeederActionDialog.kt new file mode 100644 index 0000000..782a493 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/actions/FeederActionDialog.kt @@ -0,0 +1,61 @@ +package eu.darken.adsbc.feeder.ui.list.actions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.fragment.app.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.R +import eu.darken.adsbc.common.lists.differ.update +import eu.darken.adsbc.common.uix.BottomSheetDialogFragment2 +import eu.darken.adsbc.databinding.FeederListActionDialogBinding +import eu.darken.bb.common.lists.setupDefaults +import javax.inject.Inject + +@AndroidEntryPoint +class FeederActionDialog : BottomSheetDialogFragment2() { + override val vm: FeederActionDialogVM by viewModels() + override lateinit var ui: FeederListActionDialogBinding + @Inject lateinit var actionsAdapter: ActionsAdapter + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + ui = FeederListActionDialogBinding.inflate(inflater, container, false) + return ui.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + ui.recyclerview.setupDefaults(actionsAdapter) + + vm.state.observe2(ui) { state -> + feederName.text = state.label + + actionsAdapter.update(state.allowedActions) + + ui.recyclerview.visibility = if (state.loading) View.INVISIBLE else View.VISIBLE + ui.progressCircular.visibility = if (state.loading) View.VISIBLE else View.INVISIBLE + if (state.finished) dismissAllowingStateLoss() + } + + vm.renameFeederEvent.observe2(ui) { feederId -> + MaterialAlertDialogBuilder(requireContext()).apply { + val alertLayout = layoutInflater.inflate(R.layout.feeder_list_rename_dialog, null) + val input: EditText = alertLayout.findViewById(R.id.input_text) + setView(alertLayout) + setTitle(R.string.rename_feeder_title) + setMessage(R.string.rename_feeder_description) + setPositiveButton(R.string.feeder_rename_action) { _, _ -> + vm.renameFeeder(feederId, input.text.toString()) + } + setNegativeButton(R.string.general_cancel_action) { _, _ -> } + setNeutralButton(R.string.general_reset_action) { _, _ -> + vm.renameFeeder(feederId, null) + } + }.show() + } + + super.onViewCreated(view, savedInstanceState) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/feeder/ui/list/actions/FeederActionDialogVM.kt b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/actions/FeederActionDialogVM.kt new file mode 100644 index 0000000..1859e9a --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/feeder/ui/list/actions/FeederActionDialogVM.kt @@ -0,0 +1,104 @@ +package eu.darken.adsbc.feeder.ui.list.actions + +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.flow.DynamicStateFlow +import eu.darken.adsbc.common.livedata.SingleLiveEvent +import eu.darken.adsbc.common.navigation.navArgs +import eu.darken.adsbc.common.ui.Confirmable +import eu.darken.adsbc.common.uix.ViewModel3 +import eu.darken.adsbc.feeder.core.FeederId +import eu.darken.adsbc.feeder.core.FeederRepo +import eu.darken.adsbc.feeder.core.FeederUpdateService +import eu.darken.adsbc.feeder.core.feeder +import eu.darken.adsbc.feeder.ui.list.actions.FeederAction.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +@HiltViewModel +class FeederActionDialogVM @Inject constructor( + handle: SavedStateHandle, + dispatcherProvider: DispatcherProvider, + private val feederRepo: FeederRepo, + private val feederUpdateService: FeederUpdateService, +) : ViewModel3(dispatcherProvider) { + + private val navArgs by handle.navArgs() + private val feederId: FeederId = navArgs.feederId + private val stateUpdater = DynamicStateFlow(TAG, vmScope) { State(loading = true) } + + val renameFeederEvent = SingleLiveEvent() + + val state = stateUpdater.asLiveData2() + + init { + launch { + val feeder = feederRepo.feeder(feederId).first() + + val actions = listOf( + Confirmable(REFRESH) { feederAction(it) }, + Confirmable(RENAME) { feederAction(it) }, + Confirmable(DELETE, requiredLvl = 1) { feederAction(it) }, + ) + + stateUpdater.updateBlocking { + if (feeder == null) { + copy(loading = true, finished = true) + } else { + copy( + label = feeder.label, + loading = false, + allowedActions = actions + ) + } + } + } + } + + private fun feederAction(action: FeederAction) = launch { + stateUpdater.updateBlocking { copy(loading = true) } + + when (action) { + RENAME -> { + delay(200) + renameFeederEvent.postValue(feederId) + } + REFRESH -> { + try { + feederUpdateService.updateFeeder(feederId) + } finally { + stateUpdater.updateBlocking { copy(loading = false, finished = true) } + } + } + DELETE -> { + delay(200) + feederRepo.delete(feederId) + stateUpdater.updateBlocking { copy(loading = false, finished = true) } + } + } + } + + fun renameFeeder(id: FeederId, label: String?) = launch { + log(TAG) { "renameFeeder(id=$id, label=$label)" } + + feederRepo.update(id) { it.copy(label = label) } + stateUpdater.updateBlocking { copy(loading = false, finished = true) } + } + + data class State( + val loading: Boolean = false, + val finished: Boolean = false, + val label: String = "", + val allowedActions: List> = listOf() + ) + + companion object { + private val TAG = logTag("Feeder", "ActionDialog", "VDC") + } + +} + diff --git a/app/src/main/java/eu/darken/adsbc/main/App.kt b/app/src/main/java/eu/darken/adsbc/main/App.kt new file mode 100644 index 0000000..75abec7 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/App.kt @@ -0,0 +1,41 @@ +package eu.darken.adsbc.main + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp +import eu.darken.adsbc.BuildConfig +import eu.darken.adsbc.common.coroutine.AppScope +import eu.darken.adsbc.common.debug.autoreport.AutoReporting +import eu.darken.adsbc.common.debug.logging.* +import eu.darken.adsbc.feeder.core.FeederUpdateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import javax.inject.Inject + +@HiltAndroidApp +class App : Application() { + + @Inject lateinit var bugReporter: AutoReporting + @Inject lateinit var feederUpdateService: FeederUpdateService + @AppScope @Inject 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() + + feederUpdateService + .autoUpdates + .launchIn(appScope) + + log(TAG) { "onCreate() done! ${Exception().asLog()}" } + } + + companion object { + internal val TAG = logTag("ADSBC") + } +} diff --git a/app/src/main/java/eu/darken/adsbc/main/core/GeneralSettings.kt b/app/src/main/java/eu/darken/adsbc/main/core/GeneralSettings.kt new file mode 100644 index 0000000..02a852a --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/core/GeneralSettings.kt @@ -0,0 +1,37 @@ +package eu.darken.adsbc.main.core + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.adsbc.common.debug.autoreport.DebugSettings +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.preferences.PreferenceStoreMapper +import eu.darken.adsbc.common.preferences.Settings +import eu.darken.adsbc.common.preferences.createFlowPreference +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GeneralSettings @Inject constructor( + @ApplicationContext private val context: Context, + private val debugSettings: DebugSettings, +) : Settings() { + + override val preferences: SharedPreferences = context.getSharedPreferences("settings_core", Context.MODE_PRIVATE) + + val isBugTrackingEnabled = preferences.createFlowPreference("core.bugtracking.enabled", true) + + val updateInterval = preferences.createFlowPreference("core.update.interval", 10000L) + + override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper( + isBugTrackingEnabled, + updateInterval, + debugSettings.isAutoReportingEnabled, + debugSettings.isDebugModeEnabled + ) + + companion object { + internal val TAG = logTag("General", "Settings") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/core/SomeRepo.kt b/app/src/main/java/eu/darken/adsbc/main/core/SomeRepo.kt new file mode 100644 index 0000000..cca8399 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/core/SomeRepo.kt @@ -0,0 +1,53 @@ +package eu.darken.adsbc.main.core + +import eu.darken.adsbc.common.coroutine.AppScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.isActive +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SomeRepo @Inject constructor( + @AppScope private val appCoroutineScope: CoroutineScope, +) { + + val countsWhileSubscribed: Flow = flow { + var sub = 0L + while (currentCoroutineContext().isActive) { + val toEmit = sub++ +// log { "Emitting (sub) $toEmit" } + emit(toEmit) + delay(1_000) + } + } + + val countsAlways: Flow = flow { + var counter = 0L + while (currentCoroutineContext().isActive) { + val toEmit = counter++ +// log { "Emitting (perm) $toEmit" } + emit(toEmit) + delay(1_000) + } + }.shareIn( + scope = appCoroutineScope, + started = SharingStarted.Lazily, + replay = 1 + ) + + val emojis: Flow = flow { + val emoji = EMOJIS[(Math.random() * EMOJIS.size).toInt()] +// log { "Emitting $emoji" } + emit(emoji) + } + + companion object { + internal val EMOJIS = listOf("\uD83D\uDE00", "\uD83D\uDE02", "\uD83E\uDD17", "\uD83D\uDE32") + } +} diff --git a/app/src/main/java/eu/darken/adsbc/main/core/receiver/ExampleReceiver.kt b/app/src/main/java/eu/darken/adsbc/main/core/receiver/ExampleReceiver.kt new file mode 100644 index 0000000..14dca1e --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/core/receiver/ExampleReceiver.kt @@ -0,0 +1,24 @@ +package eu.darken.adsbc.main.core.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.common.debug.logging.Logging.Priority.WARN +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.main.core.SomeRepo +import javax.inject.Inject + +@AndroidEntryPoint +class ExampleReceiver : BroadcastReceiver() { + + @Inject lateinit var someRepo: SomeRepo + + override fun onReceive(context: Context, intent: Intent) { + log { "onReceive($context, $intent)" } + if (intent.action != Intent.ACTION_HEADSET_PLUG) { + log(WARN) { "Unknown action: $intent.action" } + return + } + } +} diff --git a/app/src/main/java/eu/darken/adsbc/main/core/service/ExampleBinder.kt b/app/src/main/java/eu/darken/adsbc/main/core/service/ExampleBinder.kt new file mode 100644 index 0000000..757d842 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/core/service/ExampleBinder.kt @@ -0,0 +1,9 @@ +package eu.darken.adsbc.main.core.service + +import android.os.Binder +import javax.inject.Inject + + +class ExampleBinder @Inject constructor( +// service: ExampleService +) : Binder() diff --git a/app/src/main/java/eu/darken/adsbc/main/core/service/ExampleService.kt b/app/src/main/java/eu/darken/adsbc/main/core/service/ExampleService.kt new file mode 100644 index 0000000..bf65dfe --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/core/service/ExampleService.kt @@ -0,0 +1,17 @@ +package eu.darken.adsbc.main.core.service + +import android.content.Intent +import android.os.IBinder +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.common.uix.Service2 +import javax.inject.Inject + + +@AndroidEntryPoint +class ExampleService : Service2() { + + @Inject lateinit var binder: ExampleBinder + + override fun onBind(intent: Intent): IBinder = binder + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/MainActivity.kt b/app/src/main/java/eu/darken/adsbc/main/ui/MainActivity.kt new file mode 100644 index 0000000..6cc2ce6 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/MainActivity.kt @@ -0,0 +1,45 @@ +package eu.darken.adsbc.main.ui + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.R +import eu.darken.adsbc.common.debug.recording.core.RecorderModule +import eu.darken.adsbc.common.navigation.findNavController +import eu.darken.adsbc.common.uix.Activity2 +import eu.darken.adsbc.databinding.MainActivityBinding +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : Activity2() { + + private val vm: MainActivityVM by viewModels() + private lateinit var ui: MainActivityBinding + private val navController by lazy { supportFragmentManager.findNavController(R.id.nav_host) } + + var showSplashScreen = true + + @Inject lateinit var recorderModule: RecorderModule + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { showSplashScreen && savedInstanceState == null } + + ui = MainActivityBinding.inflate(layoutInflater) + setContentView(ui.root) + + vm.readyState.observe2 { showSplashScreen = false } + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(B_KEY_SPLASH, showSplashScreen) + super.onSaveInstanceState(outState) + } + + companion object { + private const val B_KEY_SPLASH = "showSplashScreen" + } +} diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/MainActivityVM.kt b/app/src/main/java/eu/darken/adsbc/main/ui/MainActivityVM.kt new file mode 100644 index 0000000..f275174 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/MainActivityVM.kt @@ -0,0 +1,45 @@ +package eu.darken.adsbc.main.ui + +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.uix.ViewModel2 +import eu.darken.adsbc.main.core.SomeRepo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + + +@HiltViewModel +class MainActivityVM @Inject constructor( + dispatcherProvider: DispatcherProvider, + handle: SavedStateHandle, + private val someRepo: SomeRepo +) : ViewModel2(dispatcherProvider = dispatcherProvider) { + + private val stateFlow = MutableStateFlow(State()) + val state = stateFlow + .onEach { log(VERBOSE) { "New state: $it" } } + .asLiveData2() + + private val readyStateInternal = MutableStateFlow(true) + val readyState = readyStateInternal.asLiveData2() + + init { + log { "ViewModel: $ this" } + log { "SavedStateHandle: ${handle.keys()}" } + log { "Persisted value: ${handle.get("key")}" } + handle.set("key", "valueActivity") + } + + fun onGo() { + stateFlow.value = stateFlow.value.copy(ready = true) + } + + data class State( + val ready: Boolean = false + ) + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/main/MainFragment.kt b/app/src/main/java/eu/darken/adsbc/main/ui/main/MainFragment.kt new file mode 100644 index 0000000..983ad9c --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/main/MainFragment.kt @@ -0,0 +1,90 @@ +package eu.darken.adsbc.main.ui.main + +import android.os.Bundle +import android.view.View +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.viewModels +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.R +import eu.darken.adsbc.common.colorString +import eu.darken.adsbc.common.navigation.doNavigate +import eu.darken.adsbc.common.uix.Fragment3 +import eu.darken.adsbc.common.viewbinding.viewBinding +import eu.darken.adsbc.databinding.MainFragmentBinding + +@AndroidEntryPoint +class MainFragment : Fragment3(R.layout.main_fragment) { + + override val vm: MainFragmentVM by viewModels() + override val ui: MainFragmentBinding by viewBinding() + + lateinit var adapter: MainPagerAdapter + + private var showEnterPriseUpgrade: Boolean = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + ui.toolbar.apply { + setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_settings -> { + doNavigate(MainFragmentDirections.actionExampleFragmentToSettingsContainerFragment()) + true + } + R.id.action_upgrade -> { + showUpgradeDialog() + true + } + else -> super.onOptionsItemSelected(it) + } + } + } + + vm.state.observe2(ui) { state -> + adapter = MainPagerAdapter(childFragmentManager, lifecycle, state.pages) + + viewpager.adapter = adapter + tablayout.tabMode = TabLayout.MODE_SCROLLABLE + // When smoothScroll is enabled and we navigate to an unloaded fragment, ??? happens we jump to the wrong position + TabLayoutMediator(tablayout, viewpager, true, false) { tab, position -> + tab.setText(adapter.pages[position].titleRes) + }.attach() + + viewpager.setCurrentItem(state.pagePosition, false) + + toolbar.menu.findItem(R.id.action_upgrade).isVisible = !state.isEnterprise + toolbar.updateTitle(state.isEnterprise) + } + + ui.viewpager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + vm.updateCurrentPage(position) + } + }) + + super.onViewCreated(view, savedInstanceState) + } + + private fun Toolbar.updateTitle(isEnterprise: Boolean) { + subtitle = if (isEnterprise) { + colorString(context, R.color.enterprise, getString(R.string.enterprise_mode_label) + " (โ•ฏยฐโ–กยฐ)โ•ฏ๏ธต โ”ปโ”โ”ป") + } else { + null + } + + } + + private fun showUpgradeDialog() { + MaterialAlertDialogBuilder(requireContext()).apply { + setTitle(R.string.general_upgrade_action) + setMessage(R.string.upgrade_enterprise_description) + setPositiveButton(R.string.general_upgrade_action) { _, _ -> + vm.upgradeEnterprise() + } + setNegativeButton(R.string.general_cancel_action) { _, _ -> } + }.show() + } +} diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/main/MainFragmentVM.kt b/app/src/main/java/eu/darken/adsbc/main/ui/main/MainFragmentVM.kt new file mode 100644 index 0000000..812bf3a --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/main/MainFragmentVM.kt @@ -0,0 +1,55 @@ +package eu.darken.adsbc.main.ui.main + +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.adsbc.R +import eu.darken.adsbc.aircraft.ui.AircraftListFragment +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.uix.ViewModel3 +import eu.darken.adsbc.enterprise.core.EnterpriseSettings +import eu.darken.adsbc.feeder.ui.list.FeederListFragment +import eu.darken.adsbc.main.core.GeneralSettings +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +@HiltViewModel +class MainFragmentVM @Inject constructor( + handle: SavedStateHandle, + dispatcherProvider: DispatcherProvider, + private val generalSettings: GeneralSettings, + private val enterpriseSettings: EnterpriseSettings, +) : ViewModel3(dispatcherProvider = dispatcherProvider) { + + private var currentPosition: Int = 0 + + data class State( + val pages: List, + val pagePosition: Int, + val isEnterprise: Boolean, + ) + + val state = combine( + generalSettings.isBugTrackingEnabled.flow, + enterpriseSettings.isEnterpriseMode.flow + ) { _, isEnterprise -> + val basePages = listOf( + MainPagerAdapter.Page(FeederListFragment::class, R.string.feeder_list_page_label), + MainPagerAdapter.Page(AircraftListFragment::class, R.string.aircraft_list_page_label), + ) + + State( + pages = basePages, + pagePosition = currentPosition, + isEnterprise = isEnterprise, + ) + }.asLiveData2() + + fun updateCurrentPage(position: Int) { + currentPosition = position + } + + fun upgradeEnterprise() { + enterpriseSettings.isEnterpriseMode.value = true + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/main/MainPagerAdapter.kt b/app/src/main/java/eu/darken/adsbc/main/ui/main/MainPagerAdapter.kt new file mode 100644 index 0000000..1c240f0 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/main/MainPagerAdapter.kt @@ -0,0 +1,39 @@ +package eu.darken.adsbc.main.ui.main + +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.viewpager2.adapter.FragmentStateAdapter +import eu.darken.adsbc.common.debug.logging.log +import kotlin.reflect.KClass + + +class MainPagerAdapter constructor( + fragmentManager: FragmentManager, + lifecycle: Lifecycle, + val pages: List +) : FragmentStateAdapter( + fragmentManager, + lifecycle +) { + + private val fragmentFactory = fragmentManager.fragmentFactory + + init { + log { "pages=$pages" } + } + + override fun getItemCount(): Int = pages.size + + override fun createFragment(position: Int): Fragment { + val targetPage = pages[position] + log { "createFragment(): targetPage=$targetPage" } + return fragmentFactory.instantiate(this.javaClass.classLoader!!, targetPage.fragmentClazz.qualifiedName!!) + } + + data class Page( + val fragmentClazz: KClass, + @StringRes val titleRes: Int + ) +} diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/settings/SettingsFragment.kt b/app/src/main/java/eu/darken/adsbc/main/ui/settings/SettingsFragment.kt new file mode 100644 index 0000000..417beac --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/settings/SettingsFragment.kt @@ -0,0 +1,120 @@ +package eu.darken.adsbc.main.ui.settings + +import android.os.Bundle +import android.os.Parcelable +import android.view.View +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.viewModels +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.R +import eu.darken.adsbc.common.uix.Fragment2 +import eu.darken.adsbc.common.viewbinding.viewBinding +import eu.darken.adsbc.databinding.SettingsFragmentBinding +import kotlinx.parcelize.Parcelize + +@AndroidEntryPoint +class SettingsFragment : Fragment2(R.layout.settings_fragment), + PreferenceFragmentCompat.OnPreferenceStartFragmentCallback { + + private val vm: SettingsFragmentVM by viewModels() + private val ui: SettingsFragmentBinding by viewBinding() + + val toolbar: Toolbar + get() = ui.toolbar + + private val screens = ArrayList() + + @Parcelize + data class Screen( + val fragmentClass: String, + val screenTitle: String? + ) : Parcelable + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + childFragmentManager.addOnBackStackChangedListener { + val backStackCnt = childFragmentManager.backStackEntryCount + val newScreenInfo = when { + backStackCnt < screens.size -> { + // We popped the backstack, restore the underlying screen infos + // If there are none left, we are at the index again + screens.removeLastOrNull() + screens.lastOrNull() ?: Screen( + fragmentClass = SettingsIndexFragment::class.qualifiedName!!, + screenTitle = getString(R.string.label_settings) + ) + } + else -> { + // We added the current fragment to the stack, the new fragment's infos were already set, do nothing. + null + } + } + + newScreenInfo?.let { setCurrentScreenInfo(it) } + } + + if (savedInstanceState == null) { + childFragmentManager + .beginTransaction() + .replace(R.id.content_frame, SettingsIndexFragment()) + .commit() + } else { + savedInstanceState.getParcelableArrayList(BKEY_SCREEN_INFOS)?.let { + screens.addAll(it) + } + screens.lastOrNull()?.let { setCurrentScreenInfo(it) } + } + + ui.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } + + super.onViewCreated(view, savedInstanceState) + } + + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putParcelableArrayList(BKEY_SCREEN_INFOS, screens) + } + + override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference): Boolean { + val screenInfo = Screen( + fragmentClass = pref.fragment!!, + screenTitle = pref.title?.toString() + ) + + val args = Bundle().apply { + putAll(pref.extras) + putString(BKEY_SCREEN_TITLE, screenInfo.screenTitle) + } + + val fragment = childFragmentManager.fragmentFactory + .instantiate(this::class.java.classLoader!!, pref.fragment!!) + .apply { + arguments = args + setTargetFragment(caller, 0) + } + + setCurrentScreenInfo(screenInfo) + screens.add(screenInfo) + + childFragmentManager.beginTransaction().apply { + replace(R.id.content_frame, fragment) + addToBackStack(null) + }.commit() + + return true + } + + + private fun setCurrentScreenInfo(info: Screen) { + ui.toolbar.apply { + title = info.screenTitle + } + } + + companion object { + private const val BKEY_SCREEN_TITLE = "preferenceScreenTitle" + private const val BKEY_SCREEN_INFOS = "preferenceScreenInfos" + } +} diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/settings/SettingsFragmentVM.kt b/app/src/main/java/eu/darken/adsbc/main/ui/settings/SettingsFragmentVM.kt new file mode 100644 index 0000000..3825611 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/settings/SettingsFragmentVM.kt @@ -0,0 +1,13 @@ +package eu.darken.adsbc.main.ui.settings + +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.uix.ViewModel2 +import javax.inject.Inject + +@HiltViewModel +class SettingsFragmentVM @Inject constructor( + private val handle: SavedStateHandle, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel2(dispatcherProvider) \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/settings/SettingsIndexFragment.kt b/app/src/main/java/eu/darken/adsbc/main/ui/settings/SettingsIndexFragment.kt new file mode 100644 index 0000000..7a7bb96 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/settings/SettingsIndexFragment.kt @@ -0,0 +1,41 @@ +package eu.darken.adsbc.main.ui.settings + +import android.os.Bundle +import android.view.View +import androidx.preference.Preference +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.R +import eu.darken.adsbc.common.BuildConfigWrap +import eu.darken.adsbc.common.WebpageTool +import eu.darken.adsbc.common.preferences.Settings +import eu.darken.adsbc.common.uix.PreferenceFragment2 +import eu.darken.adsbc.main.core.GeneralSettings +import javax.inject.Inject + +@AndroidEntryPoint +class SettingsIndexFragment : PreferenceFragment2() { + + @Inject lateinit var generalSettings: GeneralSettings + override val settings: Settings + get() = generalSettings + override val preferenceFile: Int = R.xml.preferences_index + + @Inject lateinit var webpageTool: WebpageTool + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupMenu(R.menu.menu_settings_index) { item -> + when (item.itemId) { + R.id.menu_item_twitter -> { + webpageTool.open("https://twitter.com/d4rken") + } + } + } + super.onViewCreated(view, savedInstanceState) + } + + override fun onPreferencesCreated() { + findPreference("core.changelog")!!.summary = BuildConfigWrap.VERSION_DESCRIPTION + + super.onPreferencesCreated() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/settings/acks/AcknowledgementsFragment.kt b/app/src/main/java/eu/darken/adsbc/main/ui/settings/acks/AcknowledgementsFragment.kt new file mode 100644 index 0000000..50095c0 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/settings/acks/AcknowledgementsFragment.kt @@ -0,0 +1,22 @@ +package eu.darken.adsbc.main.ui.settings.acks + +import androidx.annotation.Keep +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.R +import eu.darken.adsbc.common.uix.PreferenceFragment2 +import eu.darken.adsbc.main.core.GeneralSettings +import javax.inject.Inject + +@Keep +@AndroidEntryPoint +class AcknowledgementsFragment : PreferenceFragment2() { + + private val vm: AcknowledgementsFragmentVM by viewModels() + + override val preferenceFile: Int = R.xml.preferences_acknowledgements + @Inject lateinit var debugSettings: GeneralSettings + + override val settings: GeneralSettings by lazy { debugSettings } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/settings/acks/AcknowledgementsFragmentVM.kt b/app/src/main/java/eu/darken/adsbc/main/ui/settings/acks/AcknowledgementsFragmentVM.kt new file mode 100644 index 0000000..85dac0d --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/settings/acks/AcknowledgementsFragmentVM.kt @@ -0,0 +1,17 @@ +package eu.darken.adsbc.main.ui.settings.acks + +import androidx.lifecycle.SavedStateHandle +import dagger.assisted.AssistedInject +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.uix.ViewModel3 + +class AcknowledgementsFragmentVM @AssistedInject constructor( + private val handle: SavedStateHandle, + private val dispatcherProvider: DispatcherProvider +) : ViewModel3(dispatcherProvider) { + + companion object { + private val TAG = logTag("Settings", "Acknowledgements", "VM") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/settings/general/GeneralSettingsFragment.kt b/app/src/main/java/eu/darken/adsbc/main/ui/settings/general/GeneralSettingsFragment.kt new file mode 100644 index 0000000..2c23122 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/settings/general/GeneralSettingsFragment.kt @@ -0,0 +1,62 @@ +package eu.darken.adsbc.main.ui.settings.general + +import androidx.annotation.Keep +import androidx.fragment.app.viewModels +import androidx.preference.Preference +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.R +import eu.darken.adsbc.common.debug.logging.Logging.Priority.ERROR +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.uix.PreferenceFragment2 +import eu.darken.adsbc.databinding.SettingsNumberInputDialogBinding +import eu.darken.adsbc.main.core.GeneralSettings +import javax.inject.Inject + +@Keep +@AndroidEntryPoint +class GeneralSettingsFragment : PreferenceFragment2() { + + private val vdc: GeneralSettingsFragmentVM by viewModels() + + @Inject lateinit var generalSettings: GeneralSettings + + override val settings: GeneralSettings by lazy { generalSettings } + override val preferenceFile: Int = R.xml.preferences_general + + override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { + "feeder.update.interval" -> showFeederUpdateIntervalDialog(preference) + else -> super.onPreferenceTreeClick(preference) + } + + private fun showFeederUpdateIntervalDialog(preference: Preference): Boolean { + MaterialAlertDialogBuilder(requireContext()).apply { + val layout = SettingsNumberInputDialogBinding.inflate(layoutInflater) + + layout.inputText.setText(generalSettings.updateInterval.value.let { it / 1000 }.toString()) + layout.inputLayout.hint = "Seconds" + + setView(layout.root) + setTitle("Feeder update interval") + setMessage("Time in seconds how often each feeder should be polled.") + setPositiveButton(R.string.general_set_action) { _, _ -> + val textToParse = layout.inputText.text.toString() + try { + generalSettings.updateInterval.value = textToParse.toLong() * 1000L + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to update feeder update interval with $textToParse" } + } + } + setNegativeButton(R.string.general_cancel_action) { _, _ -> } + setNeutralButton(R.string.general_reset_action) { _, _ -> + generalSettings.updateInterval.reset() + } + }.show() + return true + } + + companion object { + private val TAG = logTag("Settings", "General") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/settings/general/GeneralSettingsFragmentVM.kt b/app/src/main/java/eu/darken/adsbc/main/ui/settings/general/GeneralSettingsFragmentVM.kt new file mode 100644 index 0000000..894e850 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/settings/general/GeneralSettingsFragmentVM.kt @@ -0,0 +1,20 @@ +package eu.darken.adsbc.main.ui.settings.general + +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.uix.ViewModel3 +import javax.inject.Inject + +@HiltViewModel +class GeneralSettingsFragmentVM @Inject constructor( + private val handle: SavedStateHandle, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel3(dispatcherProvider) { + + + companion object { + private val TAG = logTag("Settings", "General", "VM") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/settings/general/advanced/AdvancedSettingsFragment.kt b/app/src/main/java/eu/darken/adsbc/main/ui/settings/general/advanced/AdvancedSettingsFragment.kt new file mode 100644 index 0000000..46a70db --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/settings/general/advanced/AdvancedSettingsFragment.kt @@ -0,0 +1,22 @@ +package eu.darken.adsbc.main.ui.settings.general.advanced + +import androidx.annotation.Keep +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.R +import eu.darken.adsbc.common.uix.PreferenceFragment2 +import eu.darken.adsbc.main.core.GeneralSettings +import javax.inject.Inject + +@Keep +@AndroidEntryPoint +class AdvancedSettingsFragment : PreferenceFragment2() { + + private val vdc: AdvancedSettingsFragmentVM by viewModels() + + @Inject lateinit var debugSettings: GeneralSettings + + override val settings: GeneralSettings by lazy { debugSettings } + override val preferenceFile: Int = R.xml.preferences_advanced + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/settings/general/advanced/AdvancedSettingsFragmentVM.kt b/app/src/main/java/eu/darken/adsbc/main/ui/settings/general/advanced/AdvancedSettingsFragmentVM.kt new file mode 100644 index 0000000..9ed1caf --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/settings/general/advanced/AdvancedSettingsFragmentVM.kt @@ -0,0 +1,19 @@ +package eu.darken.adsbc.main.ui.settings.general.advanced + +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.common.uix.ViewModel3 +import javax.inject.Inject + +@HiltViewModel +class AdvancedSettingsFragmentVM @Inject constructor( + private val handle: SavedStateHandle, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel3(dispatcherProvider) { + + companion object { + private val TAG = logTag("Settings", "General", "Advanced", "VM") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/settings/support/SupportFragment.kt b/app/src/main/java/eu/darken/adsbc/main/ui/settings/support/SupportFragment.kt new file mode 100644 index 0000000..4beadb2 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/settings/support/SupportFragment.kt @@ -0,0 +1,59 @@ +package eu.darken.adsbc.main.ui.settings.support + +import android.os.Bundle +import android.view.View +import androidx.annotation.Keep +import androidx.fragment.app.viewModels +import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.adsbc.R +import eu.darken.adsbc.common.ClipboardHelper +import eu.darken.adsbc.common.observe2 +import eu.darken.adsbc.common.uix.PreferenceFragment2 +import eu.darken.adsbc.main.core.GeneralSettings +import javax.inject.Inject + +@Keep +@AndroidEntryPoint +class SupportFragment : PreferenceFragment2() { + + private val vm: SupportFragmentVM by viewModels() + + override val preferenceFile: Int = R.xml.preferences_support + @Inject lateinit var generalSettings: GeneralSettings + + override val settings: GeneralSettings by lazy { generalSettings } + + @Inject lateinit var clipboardHelper: ClipboardHelper + + private val installIdPref by lazy { findPreference("support.installid")!! } + private val supportMailPref by lazy { findPreference("support.email.darken")!! } + + override fun onPreferencesCreated() { + installIdPref.setOnPreferenceClickListener { + vm.copyInstallID() + true + } + supportMailPref.setOnPreferenceClickListener { + vm.sendSupportMail() + true + } + + super.onPreferencesCreated() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + vm.clipboardEvent.observe2(this) { installId -> + Snackbar.make(requireView(), installId, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.general_copy_action) { + clipboardHelper.copyToClipboard(installId) + } + .show() + } + + vm.emailEvent.observe2(this) { startActivity(it) } + + super.onViewCreated(view, savedInstanceState) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/main/ui/settings/support/SupportFragmentVM.kt b/app/src/main/java/eu/darken/adsbc/main/ui/settings/support/SupportFragmentVM.kt new file mode 100644 index 0000000..06cf93f --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/main/ui/settings/support/SupportFragmentVM.kt @@ -0,0 +1,49 @@ +package eu.darken.adsbc.main.ui.settings.support + +import android.content.Intent +import android.os.Build +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.adsbc.common.BuildConfigWrap +import eu.darken.adsbc.common.EmailTool +import eu.darken.adsbc.common.InstallId +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.livedata.SingleLiveEvent +import eu.darken.adsbc.common.uix.ViewModel3 +import javax.inject.Inject + +@HiltViewModel +class SupportFragmentVM @Inject constructor( + private val handle: SavedStateHandle, + private val emailTool: EmailTool, + private val installId: InstallId, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel3(dispatcherProvider) { + + val emailEvent = SingleLiveEvent() + val clipboardEvent = SingleLiveEvent() + + fun sendSupportMail() = launch { + + val bodyInfo = StringBuilder("\n\n\n") + + bodyInfo.append("--- Infos for the developer ---\n") + + bodyInfo.append("App version: ").append(BuildConfigWrap.VERSION_DESCRIPTION).append("\n") + + bodyInfo.append("Device: ").append(Build.FINGERPRINT).append("\n") + bodyInfo.append("Install ID: ").append(installId.id).append("\n") + + val email = EmailTool.Email( + receipients = listOf("support@darken.eu"), + subject = "[SD Maid] Question/Suggestion/Request\n", + body = bodyInfo.toString() + ) + + emailEvent.postValue(emailTool.build(email)) + } + + fun copyInstallID() = launch { + clipboardEvent.postValue(installId.id) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/stats/core/api/ADSBxStatsApi.kt b/app/src/main/java/eu/darken/adsbc/stats/core/api/ADSBxStatsApi.kt new file mode 100644 index 0000000..98822d6 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/stats/core/api/ADSBxStatsApi.kt @@ -0,0 +1,18 @@ +package eu.darken.adsbc.stats.core.api + +import okhttp3.ResponseBody +import retrofit2.http.GET +import retrofit2.http.Query +import java.time.Instant + +interface ADSBxStatsApi { + + @GET("/api/feeders/?") + suspend fun getStats(@Query("feed") feedId: String): ResponseBody + + data class FeederInfo( + val lastSeenAt: Instant, + val aircraftCountLogged: Int, + ) + +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/stats/core/api/ADSBxStatsEndpoint.kt b/app/src/main/java/eu/darken/adsbc/stats/core/api/ADSBxStatsEndpoint.kt new file mode 100644 index 0000000..abd7ec6 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/stats/core/api/ADSBxStatsEndpoint.kt @@ -0,0 +1,92 @@ +package eu.darken.adsbc.stats.core.api + +import dagger.Reusable +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.feeder.core.ADSBxId +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.time.Instant +import java.util.regex.Pattern +import javax.inject.Inject + +@Reusable +class ADSBxStatsEndpoint @Inject constructor( + private val baseClient: OkHttpClient, + private val moshiConverterFactory: MoshiConverterFactory, + private val dispatcherProvider: DispatcherProvider, +) { + private val api by lazy { + val configHttpClient = baseClient.newBuilder().apply { + + }.build() + + Retrofit.Builder() + .client(configHttpClient) + .baseUrl("https://www.adsbexchange.com/") + .addConverterFactory(moshiConverterFactory) + .build() + .create(ADSBxStatsApi::class.java) + } + + suspend fun getFeederStats(id: ADSBxId): ADSBxStatsApi.FeederInfo = withContext(dispatcherProvider.IO) { + log(TAG) { "getFeederStats(id=$id)" } + + val body = api.getStats(id.value) + + val bodyRaw = body.string() + val jsoup = Jsoup.parse(bodyRaw) + + ADSBxStatsApi.FeederInfo( + lastSeenAt = parseLastSeen(jsoup), + aircraftCountLogged = parseAircraftCountLogged(jsoup) + ) + } + + private fun parseAircraftCountLogged(doc: Document): Int { + val infoText = doc + .select("div") + .firstOrNull { it.text().contains("Aircraft count logged") } + ?.text() + ?: throw IllegalArgumentException("Failed to find div with aircraft count logged") + + val matcher = LOG_COUNT_PATTERN.matcher(infoText) + + if (!matcher.matches()) throw IllegalArgumentException("Aircraft count log pattern doesn't match") + + val logCount = matcher.group(1)!!.toInt() + log(TAG) { "Parsed aircraft log count: $logCount" } + return logCount + } + + private fun parseLastSeen(doc: Document): Instant { + val infoText = doc + .select("div") + .firstOrNull { it.text().contains("seconds since epoc") } + ?.text() + ?: throw IllegalArgumentException("Failed find div with last seen") + + val matcher = LAST_SEEN_PATTERN.matcher(infoText) + + if (!matcher.matches()) throw IllegalArgumentException("Last seen pattern doesn't match.") + + val lastSeen = Instant.ofEpochMilli((matcher.group(1)!!.toDouble() * 1000).toLong()) + log(TAG) { "Parsed last seen: $lastSeen" } + return lastSeen + } + + companion object { + private val LOG_COUNT_PATTERN = Pattern.compile( + "^Aircraft count logged (\\d+) times \\(every 60 seconds\\).+?\$" + ) + private val LAST_SEEN_PATTERN = Pattern.compile( + "^.+?last seen \\((\\d+\\.\\d+) seconds since epoc\\).+?\$" + ) + private val TAG = logTag("Feeder", "Stats", "Endpoint") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/stats/core/storage/FeederStatsDao.kt b/app/src/main/java/eu/darken/adsbc/stats/core/storage/FeederStatsDao.kt new file mode 100644 index 0000000..1a622d6 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/stats/core/storage/FeederStatsDao.kt @@ -0,0 +1,27 @@ +package eu.darken.adsbc.stats.core.storage + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import eu.darken.adsbc.feeder.core.FeederId +import kotlinx.coroutines.flow.Flow + +@Dao +interface FeederStatsDao { + + @Query("SELECT * FROM stats_feeder") + fun allFeederStats(): Flow> + + @Query("SELECT * FROM stats_feeder WHERE uid IN (:ids)") + fun getFeederStatsByFeederId(vararg ids: FeederId): FeederStatsData? + + @Insert + suspend fun insertStats(stats: FeederStatsData) + + @Update(entity = FeederStatsData::class) + suspend fun updateStats(stats: FeederStatsData) + + @Query("DELETE FROM stats_feeder WHERE uid = :id") + suspend fun deleteStats(id: FeederId) +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/stats/core/storage/FeederStatsData.kt b/app/src/main/java/eu/darken/adsbc/stats/core/storage/FeederStatsData.kt new file mode 100644 index 0000000..ab286a7 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/stats/core/storage/FeederStatsData.kt @@ -0,0 +1,15 @@ +package eu.darken.adsbc.stats.core.storage + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import eu.darken.adsbc.feeder.core.FeederId +import java.time.Instant + +@Entity(tableName = "stats_feeder") +data class FeederStatsData( + @PrimaryKey val uid: FeederId, + + @ColumnInfo(name = "statsUpdatedAt") val statsUpdatedAt: Instant = Instant.MIN, + @ColumnInfo(name = "aircraftCountLogged") val aircraftCountLogged: Int = 0, +) \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/stats/core/storage/StatsDatabase.kt b/app/src/main/java/eu/darken/adsbc/stats/core/storage/StatsDatabase.kt new file mode 100644 index 0000000..3eb35db --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/stats/core/storage/StatsDatabase.kt @@ -0,0 +1,26 @@ +package eu.darken.adsbc.stats.core.storage + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.adsbc.common.room.CommonConverters +import javax.inject.Inject + + +@Database( + entities = [FeederStatsData::class], + version = 1 +) +@TypeConverters(CommonConverters::class) +abstract class StatsDatabase : RoomDatabase() { + abstract fun statsDao(): FeederStatsDao + + class Factory @Inject constructor(@ApplicationContext private val context: Context) { + fun create(): StatsDatabase = Room + .databaseBuilder(context, StatsDatabase::class.java, "stats.db") + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/stats/core/storage/StatsStorage.kt b/app/src/main/java/eu/darken/adsbc/stats/core/storage/StatsStorage.kt new file mode 100644 index 0000000..ad7d4ee --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/stats/core/storage/StatsStorage.kt @@ -0,0 +1,59 @@ +package eu.darken.adsbc.stats.core.storage + +import androidx.room.withTransaction +import eu.darken.adsbc.common.debug.logging.Logging.Priority.ERROR +import eu.darken.adsbc.common.debug.logging.log +import eu.darken.adsbc.common.debug.logging.logTag +import eu.darken.adsbc.feeder.core.FeederId +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StatsStorage @Inject constructor( + private val statsDatabaseFactory: StatsDatabase.Factory, +) { + + private val db by lazy { statsDatabaseFactory.create() } + private val statsDao by lazy { db.statsDao() } + + val allFeederStats = statsDao.allFeederStats() + + suspend fun create(id: FeederId, stats: FeederStatsData? = null): FeederStatsData { + log(TAG) { "create(id=$id, stats=$stats)" } + + return db.withTransaction { + val existing = statsDao.getFeederStatsByFeederId(id) + if (existing != null) { + log(TAG, ERROR) { "Stats already exist: $id" } + throw IllegalStateException("$id already exists") + } + + val newStats = FeederStatsData(uid = id) + + statsDao.insertStats(newStats) + statsDao.getFeederStatsByFeederId(id)!!.also { + log(TAG) { "Stats created: $it" } + } + } + } + + suspend fun delete(feederId: FeederId) { + log(TAG) { "delete(feederId=$feederId)" } + statsDao.deleteStats(feederId) + } + + suspend fun update(id: FeederId, block: (FeederStatsData) -> FeederStatsData) { + log(TAG) { "update(id=$id, block=$block)" } + + db.withTransaction { + val oldFeeder = statsDao.getFeederStatsByFeederId(id) ?: return@withTransaction + val updatedStats = block(oldFeeder) + log(TAG) { "Updated feeder stats for $id\nBefore:$oldFeeder\nNow: $oldFeeder" } + statsDao.updateStats(updatedStats) + } + } + + companion object { + private val TAG = logTag("Stats", "Storage") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/adsbc/stats/core/storage/StatsStorageExtensions.kt b/app/src/main/java/eu/darken/adsbc/stats/core/storage/StatsStorageExtensions.kt new file mode 100644 index 0000000..45a7a20 --- /dev/null +++ b/app/src/main/java/eu/darken/adsbc/stats/core/storage/StatsStorageExtensions.kt @@ -0,0 +1,9 @@ +package eu.darken.adsbc.stats.core.storage + +import eu.darken.adsbc.feeder.core.FeederId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +fun StatsStorage.byFeederId(feederId: FeederId): Flow = allFeederStats.map { fs -> + fs.singleOrNull { it.uid == feederId } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_add_circle_outline_24.xml b/app/src/main/res/drawable/ic_baseline_add_circle_outline_24.xml new file mode 100644 index 0000000..761b0b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_add_circle_outline_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml new file mode 100644 index 0000000..e9a2ed4 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_av_timer_24.xml b/app/src/main/res/drawable/ic_baseline_av_timer_24.xml new file mode 100644 index 0000000..625c186 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_av_timer_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_bug_report_24.xml b/app/src/main/res/drawable/ic_baseline_bug_report_24.xml new file mode 100644 index 0000000..bc724e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_bug_report_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_business_center_24.xml b/app/src/main/res/drawable/ic_baseline_business_center_24.xml new file mode 100644 index 0000000..443df8c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_business_center_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_delete_sweep_24.xml b/app/src/main/res/drawable/ic_baseline_delete_sweep_24.xml new file mode 100644 index 0000000..3683aa4 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_delete_sweep_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_drive_file_rename_outline_24.xml b/app/src/main/res/drawable/ic_baseline_drive_file_rename_outline_24.xml new file mode 100644 index 0000000..1e472a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_drive_file_rename_outline_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_info_24.xml b/app/src/main/res/drawable/ic_baseline_info_24.xml new file mode 100644 index 0000000..7396ebc --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_info_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_radio_24.xml b/app/src/main/res/drawable/ic_baseline_radio_24.xml new file mode 100644 index 0000000..469bca5 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_radio_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_refresh_24.xml b/app/src/main/res/drawable/ic_baseline_refresh_24.xml new file mode 100644 index 0000000..566500b --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_refresh_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_science_24.xml b/app/src/main/res/drawable/ic_baseline_science_24.xml new file mode 100644 index 0000000..d0d5edc --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_science_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_settings_24.xml b/app/src/main/res/drawable/ic_baseline_settings_24.xml new file mode 100644 index 0000000..d99a8ae --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_settings_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_card_text_onsurface.xml b/app/src/main/res/drawable/ic_card_text_onsurface.xml new file mode 100644 index 0000000..4d50718 --- /dev/null +++ b/app/src/main/res/drawable/ic_card_text_onsurface.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_changelog_onsurface.xml b/app/src/main/res/drawable/ic_changelog_onsurface.xml new file mode 100644 index 0000000..cc089a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_changelog_onsurface.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_email_onsurface.xml b/app/src/main/res/drawable/ic_email_onsurface.xml new file mode 100644 index 0000000..d26645f --- /dev/null +++ b/app/src/main/res/drawable/ic_email_onsurface.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml new file mode 100644 index 0000000..12b07aa --- /dev/null +++ b/app/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_id_onsurface.xml b/app/src/main/res/drawable/ic_id_onsurface.xml new file mode 100644 index 0000000..324e614 --- /dev/null +++ b/app/src/main/res/drawable/ic_id_onsurface.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_spider_thread_onsurface.xml b/app/src/main/res/drawable/ic_spider_thread_onsurface.xml new file mode 100644 index 0000000..94a8028 --- /dev/null +++ b/app/src/main/res/drawable/ic_spider_thread_onsurface.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_twitter.xml b/app/src/main/res/drawable/ic_twitter.xml new file mode 100644 index 0000000..ac5a0db --- /dev/null +++ b/app/src/main/res/drawable/ic_twitter.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/launch_screen.xml b/app/src/main/res/drawable/launch_screen.xml new file mode 100644 index 0000000..4f52557 --- /dev/null +++ b/app/src/main/res/drawable/launch_screen.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/aircraft_list_action_dialog.xml b/app/src/main/res/layout/aircraft_list_action_dialog.xml new file mode 100644 index 0000000..357d07f --- /dev/null +++ b/app/src/main/res/layout/aircraft_list_action_dialog.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/aircraft_list_fragment.xml b/app/src/main/res/layout/aircraft_list_fragment.xml new file mode 100644 index 0000000..ea32e81 --- /dev/null +++ b/app/src/main/res/layout/aircraft_list_fragment.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/aircraft_list_line.xml b/app/src/main/res/layout/aircraft_list_line.xml new file mode 100644 index 0000000..0cd7729 --- /dev/null +++ b/app/src/main/res/layout/aircraft_list_line.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/debug_recording_activity.xml b/app/src/main/res/layout/debug_recording_activity.xml new file mode 100644 index 0000000..d2cdff3 --- /dev/null +++ b/app/src/main/res/layout/debug_recording_activity.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/feeder_list_action_dialog.xml b/app/src/main/res/layout/feeder_list_action_dialog.xml new file mode 100644 index 0000000..357d07f --- /dev/null +++ b/app/src/main/res/layout/feeder_list_action_dialog.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/feeder_list_fragment.xml b/app/src/main/res/layout/feeder_list_fragment.xml new file mode 100644 index 0000000..bcbd8f4 --- /dev/null +++ b/app/src/main/res/layout/feeder_list_fragment.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/feeder_list_input_dialog.xml b/app/src/main/res/layout/feeder_list_input_dialog.xml new file mode 100644 index 0000000..1b27bc0 --- /dev/null +++ b/app/src/main/res/layout/feeder_list_input_dialog.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/feeder_list_line.xml b/app/src/main/res/layout/feeder_list_line.xml new file mode 100644 index 0000000..41dcf53 --- /dev/null +++ b/app/src/main/res/layout/feeder_list_line.xml @@ -0,0 +1,44 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/feeder_list_rename_dialog.xml b/app/src/main/res/layout/feeder_list_rename_dialog.xml new file mode 100644 index 0000000..47cb129 --- /dev/null +++ b/app/src/main/res/layout/feeder_list_rename_dialog.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml new file mode 100644 index 0000000..b6480fc --- /dev/null +++ b/app/src/main/res/layout/main_activity.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/main_fragment.xml b/app/src/main/res/layout/main_fragment.xml new file mode 100644 index 0000000..a72c0fd --- /dev/null +++ b/app/src/main/res/layout/main_fragment.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/settings_fragment.xml b/app/src/main/res/layout/settings_fragment.xml new file mode 100644 index 0000000..d6b9e87 --- /dev/null +++ b/app/src/main/res/layout/settings_fragment.xml @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/settings_number_input_dialog.xml b/app/src/main/res/layout/settings_number_input_dialog.xml new file mode 100644 index 0000000..9d49c39 --- /dev/null +++ b/app/src/main/res/layout/settings_number_input_dialog.xml @@ -0,0 +1,26 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_action_adapter_line.xml b/app/src/main/res/layout/view_action_adapter_line.xml new file mode 100644 index 0000000..ba76811 --- /dev/null +++ b/app/src/main/res/layout/view_action_adapter_line.xml @@ -0,0 +1,50 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml new file mode 100644 index 0000000..39ba1b6 --- /dev/null +++ b/app/src/main/res/menu/menu_main.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/menu/menu_settings_index.xml b/app/src/main/res/menu/menu_settings_index.xml new file mode 100644 index 0000000..c89d2d1 --- /dev/null +++ b/app/src/main/res/menu/menu_settings_index.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c9ad5f9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..c9ad5f9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d02059d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4350d52 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a6a1caf Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2898f2d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..381ffa1 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/navigation/main.xml b/app/src/main/res/navigation/main.xml new file mode 100644 index 0000000..38743df --- /dev/null +++ b/app/src/main/res/navigation/main.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..6949699 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,42 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..7333a11 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,58 @@ + + + #006D3C + #FFFFFF + #5FFEA5 + #00210E + #006874 + #FFFFFF + #90F1FF + #001F24 + #006C46 + #FFFFFF + #8DF7C2 + #002112 + #BA1B1B + #FFDAD4 + #FFFFFF + #410001 + #FBFDF7 + #191C1A + #FBFDF7 + #191C1A + #DCE5DB + #414942 + #717971 + #F0F1EC + #2E312E + #3CE18C + #3CE18C + #00391C + #00522B + #5FFEA5 + #4FD8EB + #00363D + #004F59 + #90F1FF + #71DBA8 + #003822 + #005234 + #8DF7C2 + #FFB4A9 + #930006 + #680003 + #FFDAD4 + #191C1A + #E1E3DE + #191C1A + #E1E3DE + #414942 + #C1C9C0 + #8B938B + #191C1A + #E1E3DE + #006D3C + #29D682 + #BA1B1B + #DE4343 + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c96bf23 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #688CD6 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..edb337d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,56 @@ + + ADS-B Companion + Enterprise mode + Settings + Help + Example Fragment + Another Fragment + Show another fragment + What up! This is another fragment! + Error + + Share + Done + Copy + + Size + Compressed size + Debug notifications + Recorded log file + Record debug log + + Privacy policy + Licenses + Thank you + Other + + General + General tweaks that affect the whole app. + Changelog + Acknowledgements + Documentation + Email developer + + Install ID + Automatic error reports are anonymous. Share your install ID if the developer needs to find your error reports. + Feeders + Add + Add feeder + Input your feeder ID (it\'s the short feeder ID supplied by the ADSBx stats package). + Cancel + Info + Upgrade + Upgrade to enterprise mode. + Not seen yet + Delete + Really sure? + Sure? + Refresh + Rename + Aircraft details + Aircraft + Reset + Rename feeder + Give your feeder more recognizeable a local name. Only affects the app. + Set + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..358fd29 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..13d9575 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,47 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_acknowledgements.xml b/app/src/main/res/xml/preferences_acknowledgements.xml new file mode 100644 index 0000000..685cbfa --- /dev/null +++ b/app/src/main/res/xml/preferences_acknowledgements.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_advanced.xml b/app/src/main/res/xml/preferences_advanced.xml new file mode 100644 index 0000000..227133f --- /dev/null +++ b/app/src/main/res/xml/preferences_advanced.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_general.xml b/app/src/main/res/xml/preferences_general.xml new file mode 100644 index 0000000..b3a57bd --- /dev/null +++ b/app/src/main/res/xml/preferences_general.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_index.xml b/app/src/main/res/xml/preferences_index.xml new file mode 100644 index 0000000..f578f9a --- /dev/null +++ b/app/src/main/res/xml/preferences_index.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_support.xml b/app/src/main/res/xml/preferences_support.xml new file mode 100644 index 0000000..a1e61d6 --- /dev/null +++ b/app/src/main/res/xml/preferences_support.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/eu/darken/adsbc/common/flow/DynamicStateFlowTest.kt b/app/src/test/java/eu/darken/adsbc/common/flow/DynamicStateFlowTest.kt new file mode 100644 index 0000000..838072c --- /dev/null +++ b/app/src/test/java/eu/darken/adsbc/common/flow/DynamicStateFlowTest.kt @@ -0,0 +1,351 @@ +package eu.darken.adsbc.common.flow + +import eu.darken.adsbc.common.collections.mutate +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.instanceOf +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.TestCoroutineScope +import org.junit.jupiter.api.Test +import testhelper.BaseTest +import testhelper.coroutine.runBlockingTest2 +import testhelper.flow.test +import java.io.IOException +import java.lang.Thread.sleep +import kotlin.concurrent.thread + +class DynamicStateFlowTest : BaseTest() { + + // Without an init value, there isn't a way to keep using the flow + @Test + fun `exceptions on initialization are rethrown`() { + val testScope = TestCoroutineScope() + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + coroutineContext = Dispatchers.Unconfined, + startValueProvider = { throw IOException() } + ) + runBlocking { + withTimeoutOrNull(500) { + // This blocking scope gets the init exception as the first caller + hotData.flow.firstOrNull() + } shouldBe null + } + + testScope.advanceUntilIdle() + + testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class) + } + + @Test + fun `subscription doesn't end when no subscriber is collecting, mode Lazily`() { + val testScope = TestCoroutineScope() + val valueProvider = mockk String>() + coEvery { valueProvider.invoke(any()) } returns "Test" + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + coroutineContext = Dispatchers.Unconfined, + startValueProvider = valueProvider, + ) + + testScope.apply { + runBlockingTest2(allowUncompleted = true) { + hotData.flow.first() shouldBe "Test" + hotData.flow.first() shouldBe "Test" + } + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } + } + + @Test + fun `value updates`() { + val testScope = TestCoroutineScope() + val valueProvider = mockk Long>() + coEvery { valueProvider.invoke(any()) } returns 1 + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + startValueProvider = valueProvider, + ) + + val testCollector = hotData.flow.test(startOnScope = testScope) + testCollector.silent = true + + (1..16).forEach { _ -> + thread { + (1..200).forEach { _ -> + sleep(10) + hotData.updateAsync( + onUpdate = { this + 1L }, + onError = { throw it } + ) + } + } + } + + runBlocking { + testCollector.await { list, _ -> list.size == 3201 } + testCollector.latestValues shouldBe (1..3201).toList() + } + + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } + + data class TestData( + val number: Long = 1 + ) + + @Test + fun `check multi threading value updates with more complex data`() { + val testScope = TestCoroutineScope() + val valueProvider = mockk Map>() + coEvery { valueProvider.invoke(any()) } returns mapOf("data" to TestData()) + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + startValueProvider = valueProvider, + ) + + val testCollector = hotData.flow.test(startOnScope = testScope) + testCollector.silent = true + + (1..10).forEach { _ -> + thread { + (1..400).forEach { _ -> + hotData.updateAsync { + mutate { + this["data"] = getValue("data").copy( + number = getValue("data").number + 1 + ) + } + } + } + } + } + + runBlocking { + testCollector.await { list, _ -> list.size == 4001 } + testCollector.latestValues.map { it.values.single().number } shouldBe (1L..4001L).toList() + } + + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } + + @Test + fun `only emit new values if they actually changed updates`() { + val testScope = TestCoroutineScope() + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + startValueProvider = { "1" }, + ) + + val testCollector = hotData.flow.test(startOnScope = testScope) + testCollector.silent = true + + hotData.updateAsync { "1" } + hotData.updateAsync { "2" } + hotData.updateAsync { "2" } + hotData.updateAsync { "1" } + + runBlocking { + testCollector.await { list, _ -> list.size == 3 } + testCollector.latestValues shouldBe listOf("1", "2", "1") + } + } + + @Test + fun `multiple subscribers share the flow`() { + val testScope = TestCoroutineScope() + val valueProvider = mockk String>() + coEvery { valueProvider.invoke(any()) } returns "Test" + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + startValueProvider = valueProvider, + ) + + testScope.runBlockingTest2(allowUncompleted = true) { + val sub1 = hotData.flow.test(tag = "sub1", startOnScope = this) + val sub2 = hotData.flow.test(tag = "sub2", startOnScope = this) + val sub3 = hotData.flow.test(tag = "sub3", startOnScope = this) + + hotData.updateAsync { "A" } + hotData.updateAsync { "B" } + hotData.updateAsync { "C" } + + listOf(sub1, sub2, sub3).forEach { + it.await { list, _ -> list.size == 4 } + it.latestValues shouldBe listOf("Test", "A", "B", "C") + it.cancel() + } + + hotData.flow.first() shouldBe "C" + } + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } + + @Test + fun `value is persisted between unsubscribes`() = runBlockingTest2(allowUncompleted = true) { + val valueProvider = mockk Long>() + coEvery { valueProvider.invoke(any()) } returns 1 + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = this, + coroutineContext = this.coroutineContext, + startValueProvider = valueProvider, + ) + + val testCollector1 = hotData.flow.test(tag = "collector1", startOnScope = this) + testCollector1.silent = false + + (1..10).forEach { _ -> + hotData.updateAsync { + this + 1L + } + } + + advanceUntilIdle() + + testCollector1.await { list, _ -> list.size == 11 } + testCollector1.latestValues shouldBe (1L..11L).toList() + + testCollector1.cancel() + testCollector1.awaitFinal() + + val testCollector2 = hotData.flow.test(tag = "collector2", startOnScope = this) + testCollector2.silent = false + + advanceUntilIdle() + + testCollector2.cancel() + testCollector2.awaitFinal() + + testCollector2.latestValues shouldBe listOf(11L) + + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } + + @Test + fun `blocking update is actually blocking`() = runBlocking { + val testScope = TestCoroutineScope() + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + coroutineContext = testScope.coroutineContext, + startValueProvider = { + delay(2000) + 2 + }, + ) + + hotData.updateAsync { + delay(2000) + this + 1 + } + + val testCollector = hotData.flow.test(startOnScope = testScope) + + testScope.advanceUntilIdle() + + hotData.updateBlocking { this - 3 } shouldBe 0 + + testCollector.await { _, i -> i == 3 } + testCollector.latestValues shouldBe listOf(2, 3, 0) + + testCollector.cancel() + } + + @Test + fun `blocking update rethrows error`() = runBlocking { + val testScope = TestCoroutineScope() + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + coroutineContext = testScope.coroutineContext, + startValueProvider = { + delay(2000) + 2 + }, + ) + + val testCollector = hotData.flow.test(startOnScope = testScope) + + testScope.advanceUntilIdle() + + shouldThrow { + hotData.updateBlocking { throw IOException("Surprise") } shouldBe 0 + } + hotData.flow.first() shouldBe 2 + + hotData.updateBlocking { 3 } shouldBe 3 + hotData.flow.first() shouldBe 3 + + testScope.uncaughtExceptions.singleOrNull() shouldBe null + + testCollector.cancel() + } + + @Test + fun `async updates error handler`() { + val testScope = TestCoroutineScope() + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + startValueProvider = { 1 }, + ) + + val testCollector = hotData.flow.test(startOnScope = testScope) + testScope.advanceUntilIdle() + + hotData.updateAsync { throw IOException("Surprise") } + + testScope.advanceUntilIdle() + + testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class) + + testCollector.cancel() + } + + @Test + fun `async updates rethrow errors on HotDataFlow scope if no error handler is set`() = runBlocking { + val testScope = TestCoroutineScope() + + val hotData = DynamicStateFlow( + loggingTag = "tag", + parentScope = testScope, + startValueProvider = { 1 }, + ) + + val testCollector = hotData.flow.test(startOnScope = testScope) + testScope.advanceUntilIdle() + + var thrownError: Exception? = null + + hotData.updateAsync( + onUpdate = { throw IOException("Surprise") }, + onError = { thrownError = it } + ) + + testScope.advanceUntilIdle() + thrownError!!.shouldBeInstanceOf() + testScope.uncaughtExceptions.singleOrNull() shouldBe null + + testCollector.cancel() + } +} diff --git a/app/src/test/java/eu/darken/adsbc/common/preferences/FlowPreferenceMoshiTest.kt b/app/src/test/java/eu/darken/adsbc/common/preferences/FlowPreferenceMoshiTest.kt new file mode 100644 index 0000000..e03a564 --- /dev/null +++ b/app/src/test/java/eu/darken/adsbc/common/preferences/FlowPreferenceMoshiTest.kt @@ -0,0 +1,131 @@ +package eu.darken.adsbc.common.preferences + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.Test +import testhelper.BaseTest +import testhelper.json.toComparableJson +import testhelpers.preferences.MockSharedPreferences + +class FlowPreferenceMoshiTest : BaseTest() { + + private val mockPreferences = MockSharedPreferences() + + @JsonClass(generateAdapter = true) + data class TestGson( + val string: String = "", + val boolean: Boolean = true, + val float: Float = 1.0f, + val int: Int = 1, + val long: Long = 1L + ) + + @Test + fun `reading and writing using manual reader and writer`() = runBlockingTest { + val testData1 = TestGson(string = "teststring") + val testData2 = TestGson(string = "update") + val moshi = Moshi.Builder().build() + FlowPreference( + preferences = mockPreferences, + key = "testKey", + rawReader = moshiReader(moshi, testData1), + rawWriter = moshiWriter(moshi) + ).apply { + value shouldBe testData1 + flow.first() shouldBe testData1 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe testData1 + it!!.copy(string = "update") + } + + value shouldBe testData2 + flow.first() shouldBe testData2 + (mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """ + { + "string":"update", + "boolean":true, + "float":1.0, + "int":1, + "long":1 + } + """.toComparableJson() + + update { + it shouldBe testData2 + null + } + value shouldBe testData1 + flow.first() shouldBe testData1 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing using autocreated reader and writer`() = runBlockingTest { + val testData1 = TestGson(string = "teststring") + val testData2 = TestGson(string = "update") + val moshi = Moshi.Builder().build() + + mockPreferences.createFlowPreference( + key = "testKey", + defaultValue = testData1, + moshi = moshi + ).apply { + value shouldBe testData1 + flow.first() shouldBe testData1 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe testData1 + it!!.copy(string = "update") + } + + value shouldBe testData2 + flow.first() shouldBe testData2 + (mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """ + { + "string":"update", + "boolean":true, + "float":1.0, + "int":1, + "long":1 + } + """.toComparableJson() + + update { + it shouldBe testData2 + null + } + value shouldBe testData1 + flow.first() shouldBe testData1 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + enum class Anum { + @Json(name = "a") + A, + @Json(name = "b") + B + } + + @Test + fun `enum serialization`() = runBlockingTest { + val moshi = Moshi.Builder().build() + val monitorMode = mockPreferences.createFlowPreference( + "test.enum", + Anum.A, + moshi + ) + + monitorMode.value shouldBe Anum.A + monitorMode.update { Anum.B } + monitorMode.value shouldBe Anum.B + } +} diff --git a/app/src/test/java/eu/darken/adsbc/common/preferences/FlowPreferenceTest.kt b/app/src/test/java/eu/darken/adsbc/common/preferences/FlowPreferenceTest.kt new file mode 100644 index 0000000..db10dc4 --- /dev/null +++ b/app/src/test/java/eu/darken/adsbc/common/preferences/FlowPreferenceTest.kt @@ -0,0 +1,160 @@ +package eu.darken.capod.common.preferences + +import eu.darken.adsbc.common.preferences.createFlowPreference +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.Test +import testhelper.BaseTest +import testhelpers.preferences.MockSharedPreferences + +class FlowPreferenceTest : BaseTest() { + + private val mockPreferences = MockSharedPreferences() + + @Test + fun `reading and writing strings`() = runBlockingTest { + mockPreferences.createFlowPreference( + key = "testKey", + defaultValue = "default" + ).apply { + value shouldBe "default" + flow.first() shouldBe "default" + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe "default" + "newvalue" + } + + value shouldBe "newvalue" + flow.first() shouldBe "newvalue" + mockPreferences.dataMapPeek.values.first() shouldBe "newvalue" + + update { + it shouldBe "newvalue" + null + } + value shouldBe "default" + flow.first() shouldBe "default" + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing boolean`() = runBlockingTest { + mockPreferences.createFlowPreference( + key = "testKey", + defaultValue = true + ).apply { + value shouldBe true + flow.first() shouldBe true + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe true + false + } + + value shouldBe false + flow.first() shouldBe false + mockPreferences.dataMapPeek.values.first() shouldBe false + + update { + it shouldBe false + null + } + value shouldBe true + flow.first() shouldBe true + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing long`() = runBlockingTest { + mockPreferences.createFlowPreference( + key = "testKey", + defaultValue = 9000L + ).apply { + value shouldBe 9000L + flow.first() shouldBe 9000L + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe 9000L + 9001L + } + + value shouldBe 9001L + flow.first() shouldBe 9001L + mockPreferences.dataMapPeek.values.first() shouldBe 9001L + + update { + it shouldBe 9001L + null + } + value shouldBe 9000L + flow.first() shouldBe 9000L + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing integer`() = runBlockingTest { + mockPreferences.createFlowPreference( + key = "testKey", + defaultValue = 123 + ).apply { + value shouldBe 123 + flow.first() shouldBe 123 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe 123 + 44 + } + + value shouldBe 44 + flow.first() shouldBe 44 + mockPreferences.dataMapPeek.values.first() shouldBe 44 + + update { + it shouldBe 44 + null + } + value shouldBe 123 + flow.first() shouldBe 123 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing float`() = runBlockingTest { + mockPreferences.createFlowPreference( + key = "testKey", + defaultValue = 3.6f + ).apply { + value shouldBe 3.6f + flow.first() shouldBe 3.6f + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe 3.6f + 15000f + } + + value shouldBe 15000f + flow.first() shouldBe 15000f + mockPreferences.dataMapPeek.values.first() shouldBe 15000f + + update { + it shouldBe 15000f + null + } + value shouldBe 3.6f + flow.first() shouldBe 3.6f + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + +} diff --git a/app/src/test/java/testhelper/BaseTest.kt b/app/src/test/java/testhelper/BaseTest.kt new file mode 100644 index 0000000..5169d27 --- /dev/null +++ b/app/src/test/java/testhelper/BaseTest.kt @@ -0,0 +1,29 @@ +package testhelper + +import eu.darken.adsbc.common.debug.logging.Logging +import eu.darken.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.log +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterAll +import testhelpers.logging.JUnitLogger + + +open class BaseTest { + init { + Logging.clearAll() + Logging.install(JUnitLogger()) + testClassName = this.javaClass.simpleName + } + + companion object { + private var testClassName: String? = null + + @JvmStatic + @AfterAll + fun onTestClassFinished() { + unmockkAll() + log(testClassName!!, VERBOSE) { "onTestClassFinished()" } + Logging.clearAll() + } + } +} diff --git a/app/src/test/java/testhelper/coroutine/CoroutinesTestExtension.kt b/app/src/test/java/testhelper/coroutine/CoroutinesTestExtension.kt new file mode 100644 index 0000000..900333f --- /dev/null +++ b/app/src/test/java/testhelper/coroutine/CoroutinesTestExtension.kt @@ -0,0 +1,26 @@ +package testhelper.coroutine + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +@ExperimentalCoroutinesApi +class CoroutinesTestExtension( + private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() +) : BeforeEachCallback, AfterEachCallback, TestCoroutineScope by TestCoroutineScope(dispatcher) { + + override fun beforeEach(context: ExtensionContext?) { + Dispatchers.setMain(dispatcher) + } + + override fun afterEach(context: ExtensionContext?) { + cleanupTestCoroutines() + Dispatchers.resetMain() + } +} diff --git a/app/src/test/java/testhelper/coroutine/TestDispatcherProvider.kt b/app/src/test/java/testhelper/coroutine/TestDispatcherProvider.kt new file mode 100644 index 0000000..2705b54 --- /dev/null +++ b/app/src/test/java/testhelper/coroutine/TestDispatcherProvider.kt @@ -0,0 +1,23 @@ +package testhelper.coroutine + +import eu.darken.adsbc.common.coroutine.DispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +class TestDispatcherProvider(private val context: CoroutineContext? = null) : DispatcherProvider { + override val Default: CoroutineContext + get() = context ?: Dispatchers.Unconfined + override val Main: CoroutineContext + get() = context ?: Dispatchers.Unconfined + override val MainImmediate: CoroutineContext + get() = context ?: Dispatchers.Unconfined + override val Unconfined: CoroutineContext + get() = context ?: Dispatchers.Unconfined + override val IO: CoroutineContext + get() = context ?: Dispatchers.Unconfined +} + +fun CoroutineScope.asDispatcherProvider() = this.coroutineContext.asDispatcherProvider() + +fun CoroutineContext.asDispatcherProvider() = TestDispatcherProvider(context = this) diff --git a/app/src/test/java/testhelper/coroutine/TestExtensions.kt b/app/src/test/java/testhelper/coroutine/TestExtensions.kt new file mode 100644 index 0000000..a17a5cc --- /dev/null +++ b/app/src/test/java/testhelper/coroutine/TestExtensions.kt @@ -0,0 +1,49 @@ +package testhelper.coroutine + +import eu.darken.adsbc.common.debug.logging.log +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.UncompletedCoroutinesError +import kotlinx.coroutines.test.runBlockingTest +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * If you have a test that uses a coroutine that never stops, you may use this. + */ + +@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +fun TestCoroutineScope.runBlockingTest2( + allowUncompleted: Boolean = false, + block: suspend TestCoroutineScope.() -> Unit +): Unit = runBlockingTest2( + allowUncompleted = allowUncompleted, + context = coroutineContext, + testBody = block +) + +fun runBlockingTest2( + allowUncompleted: Boolean = false, + context: CoroutineContext = EmptyCoroutineContext, + testBody: suspend TestCoroutineScope.() -> Unit +) { + try { + runBlocking { + try { + runBlockingTest( + context = context, + testBody = testBody + ) + } catch (e: UncompletedCoroutinesError) { + if (!allowUncompleted) throw e + else log { "Ignoring active job." } + } + } + } catch (e: Exception) { + if (!allowUncompleted || (e.message != "This job has not completed yet")) { + throw e + } + } +} + diff --git a/app/src/test/java/testhelper/flow/FlowTest.kt b/app/src/test/java/testhelper/flow/FlowTest.kt new file mode 100644 index 0000000..4cc3ea5 --- /dev/null +++ b/app/src/test/java/testhelper/flow/FlowTest.kt @@ -0,0 +1,101 @@ +package testhelper.flow + +import eu.darken.adsbc.common.debug.logging.Logging.Priority.WARN +import eu.darken.adsbc.common.debug.logging.asLog +import eu.darken.adsbc.common.debug.logging.log +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.TestCoroutineScope + +fun Flow.test( + tag: String? = null, + startOnScope: CoroutineScope = TestCoroutineScope() +): TestCollector = createTest(tag ?: "FlowTest").start(scope = startOnScope) + +fun Flow.createTest( + tag: String? = null +): TestCollector = TestCollector(this, tag ?: "FlowTest") + +class TestCollector( + private val flow: Flow, + private val tag: String + +) { + private var error: Throwable? = null + private lateinit var job: Job + private val cache = MutableSharedFlow( + replay = Int.MAX_VALUE, + extraBufferCapacity = Int.MAX_VALUE, + onBufferOverflow = BufferOverflow.SUSPEND + ) + private var latestInternal: T? = null + private val collectedValuesMutex = Mutex() + private val collectedValues = mutableListOf() + + var silent = false + + fun start(scope: CoroutineScope) = apply { + flow + .buffer(capacity = Int.MAX_VALUE) + .onStart { log(tag) { "Setting up." } } + .onCompletion { log(tag) { "Final." } } + .onEach { + collectedValuesMutex.withLock { + if (!silent) log(tag) { "Collecting: $it" } + latestInternal = it + collectedValues.add(it) + cache.emit(it) + } + } + .catch { e -> + log(tag, WARN) { "Caught error: ${e.asLog()}" } + error = e + } + .launchIn(scope) + .also { job = it } + } + + fun emissions(): Flow = cache + + val latestValue: T? + get() = collectedValues.last() + + val latestValues: List + get() = collectedValues + + fun await( + timeout: Long = 10_000, + condition: (List, T) -> Boolean + ): T = runBlocking { + withTimeout(timeMillis = timeout) { + emissions().first { + condition(collectedValues, it) + } + } + } + + suspend fun awaitFinal(cancel: Boolean = false) = apply { + if (cancel) cancel() + try { + job.join() + } catch (e: Exception) { + error = e + } + } + + suspend fun assertNoErrors() = apply { + awaitFinal() + require(error == null) { "Error was not null: $error" } + } + + fun cancel() { + if (job.isCompleted) throw IllegalStateException("Flow is already canceled.") + + runBlocking { + job.cancelAndJoin() + } + } +} diff --git a/app/src/test/java/testhelper/json/JsonExtensions.kt b/app/src/test/java/testhelper/json/JsonExtensions.kt new file mode 100644 index 0000000..13c56ea --- /dev/null +++ b/app/src/test/java/testhelper/json/JsonExtensions.kt @@ -0,0 +1,29 @@ +package testhelper.json + +import com.squareup.moshi.JsonReader +import com.squareup.moshi.Moshi +import okio.Buffer +import okio.ByteString.Companion.encode +import okio.buffer +import okio.sink +import java.io.File + + +fun String.toComparableJson(): String { + val value = Buffer().use { + it.writeUtf8(this) + val reader = JsonReader.of(it) + reader.readJsonValue() + } + + val adapter = Moshi.Builder().build().adapter(Any::class.java).indent(" ") + + return adapter.toJson(value) +} + +fun String.writeToFile(file: File) = encode().let { text -> + require(!file.exists()) + file.parentFile?.mkdirs() + file.createNewFile() + file.sink().buffer().use { it.write(text) } +} \ No newline at end of file diff --git a/app/src/test/java/testhelper/livedata/InstantExecutorExtension.kt b/app/src/test/java/testhelper/livedata/InstantExecutorExtension.kt new file mode 100644 index 0000000..debd59d --- /dev/null +++ b/app/src/test/java/testhelper/livedata/InstantExecutorExtension.kt @@ -0,0 +1,26 @@ +package testhelper.livedata + +import androidx.arch.core.executor.ArchTaskExecutor +import androidx.arch.core.executor.TaskExecutor +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance().setDelegate( + object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + + override fun postToMainThread(runnable: Runnable) = runnable.run() + + override fun isMainThread(): Boolean = true + } + ) + } + + override fun afterEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance().setDelegate(null) + } +} diff --git a/app/src/test/java/testhelper/preferences/MockSharedPreferencesTest.kt b/app/src/test/java/testhelper/preferences/MockSharedPreferencesTest.kt new file mode 100644 index 0000000..f69e990 --- /dev/null +++ b/app/src/test/java/testhelper/preferences/MockSharedPreferencesTest.kt @@ -0,0 +1,22 @@ +package testhelper.preferences + +import androidx.core.content.edit +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelper.BaseTest +import testhelpers.preferences.MockSharedPreferences + +class MockSharedPreferencesTest : BaseTest() { + + private fun createInstance() = MockSharedPreferences() + + @Test + fun `test boolean insertion`() { + val prefs = createInstance() + prefs.dataMapPeek shouldBe emptyMap() + prefs.getBoolean("key", true) shouldBe true + prefs.edit { putBoolean("key", false) } + prefs.getBoolean("key", true) shouldBe false + prefs.dataMapPeek["key"] shouldBe false + } +} diff --git a/app/src/testShared/java/testhelpers/BaseTestInstrumentation.kt b/app/src/testShared/java/testhelpers/BaseTestInstrumentation.kt new file mode 100644 index 0000000..3b6b9c0 --- /dev/null +++ b/app/src/testShared/java/testhelpers/BaseTestInstrumentation.kt @@ -0,0 +1,29 @@ +package testhelpers + +import eu.darken.adsbc.common.debug.logging.Logging +import eu.darken.adsbc.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.adsbc.common.debug.logging.log +import io.mockk.unmockkAll +import org.junit.AfterClass +import testhelpers.logging.JUnitLogger + +abstract class BaseTestInstrumentation { + + init { + Logging.clearAll() + Logging.install(JUnitLogger()) + testClassName = this.javaClass.simpleName + } + + companion object { + private var testClassName: String? = null + + @JvmStatic + @AfterClass + fun onTestClassFinished() { + unmockkAll() + log(testClassName!!, VERBOSE) { "onTestClassFinished()" } + Logging.clearAll() + } + } +} diff --git a/app/src/testShared/java/testhelpers/IsAUnitTest.kt b/app/src/testShared/java/testhelpers/IsAUnitTest.kt new file mode 100644 index 0000000..2af3e4c --- /dev/null +++ b/app/src/testShared/java/testhelpers/IsAUnitTest.kt @@ -0,0 +1,3 @@ +package testhelpers + +class IsAUnitTest diff --git a/app/src/testShared/java/testhelpers/logging/JUnitLogger.kt b/app/src/testShared/java/testhelpers/logging/JUnitLogger.kt new file mode 100644 index 0000000..3221a2a --- /dev/null +++ b/app/src/testShared/java/testhelpers/logging/JUnitLogger.kt @@ -0,0 +1,13 @@ +package testhelpers.logging + +import eu.darken.adsbc.common.debug.logging.Logging + +class JUnitLogger(private val minLogLevel: Logging.Priority = Logging.Priority.VERBOSE) : Logging.Logger { + + override fun isLoggable(priority: Logging.Priority): Boolean = priority.intValue >= minLogLevel.intValue + + override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map?) { + println("${System.currentTimeMillis()} ${priority.shortLabel}/$tag: $message") + } + +} diff --git a/app/src/testShared/java/testhelpers/preferences/MockFlowPreference.kt b/app/src/testShared/java/testhelpers/preferences/MockFlowPreference.kt new file mode 100644 index 0000000..43d891c --- /dev/null +++ b/app/src/testShared/java/testhelpers/preferences/MockFlowPreference.kt @@ -0,0 +1,21 @@ +package testhelpers.preferences + +import eu.darken.adsbc.common.preferences.FlowPreference +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow + +fun mockFlowPreference( + defaultValue: T +): FlowPreference { + val instance = mockk>() + val flow = MutableStateFlow(defaultValue) + every { instance.flow } answers { flow } + every { instance.value } answers { flow.value } + every { instance.update(any()) } answers { + val updateCall = arg<(T) -> T>(0) + flow.value = updateCall(flow.value) + } + + return instance +} diff --git a/app/src/testShared/java/testhelpers/preferences/MockSharedPreferences.kt b/app/src/testShared/java/testhelpers/preferences/MockSharedPreferences.kt new file mode 100644 index 0000000..c094e56 --- /dev/null +++ b/app/src/testShared/java/testhelpers/preferences/MockSharedPreferences.kt @@ -0,0 +1,99 @@ +package testhelpers.preferences + +import android.content.SharedPreferences + +class MockSharedPreferences : SharedPreferences { + private val listeners = mutableListOf() + private val dataMap = mutableMapOf() + val dataMapPeek: Map + get() = dataMap.toMap() + + override fun getAll(): MutableMap = dataMap + + override fun getString(key: String, defValue: String?): String? = + dataMap[key] as? String ?: defValue + + override fun getStringSet(key: String, defValues: MutableSet?): MutableSet { + throw NotImplementedError() + } + + override fun getInt(key: String, defValue: Int): Int = + dataMap[key] as? Int ?: defValue + + override fun getLong(key: String, defValue: Long): Long = + dataMap[key] as? Long ?: defValue + + override fun getFloat(key: String, defValue: Float): Float { + throw NotImplementedError() + } + + override fun getBoolean(key: String, defValue: Boolean): Boolean = + dataMap[key] as? Boolean ?: defValue + + override fun contains(key: String): Boolean = dataMap.contains(key) + + override fun edit(): SharedPreferences.Editor = createEditor(dataMap.toMap()) { newData -> + dataMap.clear() + dataMap.putAll(newData) + } + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + listeners.add(listener) + } + + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + listeners.remove(listener) + } + + private fun createEditor( + toEdit: Map, + onSave: (Map) -> Unit + ): SharedPreferences.Editor { + return object : SharedPreferences.Editor { + private val editorData = toEdit.toMutableMap() + override fun putString(key: String, value: String?): SharedPreferences.Editor = apply { + value?.let { editorData[key] = it } ?: editorData.remove(key) + } + + override fun putStringSet( + key: String?, + values: MutableSet? + ): SharedPreferences.Editor { + throw NotImplementedError() + } + + override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply { + editorData[key] = value + } + + override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply { + editorData[key] = value + } + + override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply { + editorData[key] = value + } + + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply { + editorData[key] = value + } + + override fun remove(key: String): SharedPreferences.Editor = apply { + editorData.remove(key) + } + + override fun clear(): SharedPreferences.Editor = apply { + editorData.clear() + } + + override fun commit(): Boolean { + onSave(editorData) + return true + } + + override fun apply() { + onSave(editorData) + } + } + } +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..068ebb1 --- /dev/null +++ b/build.gradle @@ -0,0 +1,54 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + ext.buildConfig = [ + 'compileSdk': 31, + 'minSdk' : 26, + 'targetSdk' : 31, + + 'version' : [ + 'major': 0, + 'minor': 0, + 'patch': 1, + 'build': 0, + ], + ] + + ext.buildConfig.version['name'] = "${buildConfig.version.major}.${buildConfig.version.minor}.${buildConfig.version.patch}" + ext.buildConfig.version['fullName'] = "${buildConfig.version.name}.${buildConfig.version.build}" + ext.buildConfig.version['code'] = buildConfig.version.major * 1000000 + buildConfig.version.minor * 10000 + buildConfig.version.patch * 100 + buildConfig.version.build + + ext.versions = [ + 'kotlin' : [ + 'core' : '1.6.10', + 'coroutines': '1.5.1' + ], + 'dagger' : [ + 'core': '2.39.1' + ], + 'androidx' : [ + 'navigation': '2.3.5' + ], + ] + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:7.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin.core}" + classpath "com.google.dagger:hilt-android-gradle-plugin:${versions.dagger.core}" + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:${versions.androidx.navigation}" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2521752 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,19 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..288d2e9 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Feb 06 15:16:44 CET 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..09f7622 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = "ADS-B Companion" +include ':app'