Go multi-module: app-common, app-phone, app-wear

This commit is contained in:
darken
2022-09-14 18:00:18 +02:00
committed by Matthias Urhahn
parent 753d311593
commit 688748da36
631 changed files with 1595 additions and 49 deletions
-104
View File
@@ -1,104 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="eu.darken.capod">
<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<application
android:name=".App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppThemeSplash">
<activity
android:name=".main.ui.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".monitor.core.receiver.BluetoothEventReceiver"
android:enabled="true"
android:exported="true"
android:label="Service trigger">
<intent-filter>
<action android:name="android.bluetooth.device.action.ACL_CONNECTED" />
<!-- <action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />-->
<!-- <action android:name="android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED" />-->
<!-- <action android:name="android.bluetooth.headset.profile.action.CONNECTION_STATE_CHANGED" />-->
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
<!-- Debug stuff-->
<activity
android:name=".common.debug.recording.ui.RecorderActivity"
android:theme="@style/AppThemeFloating" />
<service android:name=".common.debug.recording.core.RecorderService" />
<!-- Worker stuff-->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="connectedDevice"
tools:node="merge" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<meta-data
android:name="com.bugsnag.android.API_KEY"
android:value="${bugsnagApiKey}" />
</application>
</manifest>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

-49
View File
@@ -1,49 +0,0 @@
package eu.darken.capod
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.getkeepsafe.relinker.ReLinker
import dagger.hilt.android.HiltAndroidApp
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.autoreport.AutoReporting
import eu.darken.capod.common.debug.logging.*
import eu.darken.capod.monitor.core.worker.MonitorControl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltAndroidApp
open class App : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var autoReporting: AutoReporting
@Inject lateinit var monitorControl: MonitorControl
@Inject @AppScope lateinit var appScope: CoroutineScope
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
ReLinker
.log { message -> log(TAG) { "ReLinker: $message" } }
.loadLibrary(this, "bugsnag-plugin-android-anr")
autoReporting.setup()
log(TAG) { "onCreate() done! ${Exception().asLog()}" }
appScope.launch {
monitorControl.startMonitor(forceStart = true)
}
}
override fun getWorkManagerConfiguration(): Configuration = Configuration.Builder()
.setMinimumLoggingLevel(android.util.Log.VERBOSE)
.setWorkerFactory(workerFactory)
.build()
companion object {
internal val TAG = logTag("CAP")
}
}
@@ -1,46 +0,0 @@
package eu.darken.capod.common
import eu.darken.capod.BuildConfig
// Can't be const because that prevents them from being mocked in tests
@Suppress("MayBeConstant")
object BuildConfigWrap {
val DEBUG: Boolean = BuildConfig.DEBUG
val BUILD_TYPE: BuildType = when (val typ = BuildConfig.BUILD_TYPE) {
"debug" -> BuildType.DEV
"beta" -> BuildType.BETA
"release" -> BuildType.RELEASE
else -> throw IllegalArgumentException("Unknown buildtype: $typ")
}
enum class BuildType {
DEV,
BETA,
RELEASE,
;
}
val FLAVOR: Flavor = when (val flav = BuildConfig.FLAVOR) {
"gplay" -> Flavor.GPLAY
"foss" -> Flavor.FOSS
else -> throw IllegalStateException("Unknown flavor: $flav")
}
enum class Flavor {
GPLAY,
FOSS,
;
}
val APPLICATION_ID = BuildConfig.APPLICATION_ID
val VERSION_CODE: Long = BuildConfig.VERSION_CODE.toLong()
val VERSION_NAME: String = BuildConfig.VERSION_NAME
val GIT_SHA: String = BuildConfig.GITSHA
val VERSION_DESCRIPTION_LONG: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA] ${FLAVOR}_$BUILD_TYPE"
val VERSION_DESCRIPTION_SHORT: String = "v$VERSION_NAME [$GIT_SHA] $FLAVOR"
val VERSION_DESCRIPTION_TINY: String = "v$VERSION_NAME"
}
@@ -1,18 +0,0 @@
package eu.darken.capod.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
fun withinApiLevel(start: Int, end: Int): Boolean = BuildWrap.VERSION.SDK_INT in start..end
@@ -1,20 +0,0 @@
package eu.darken.capod.common
import java.util.*
fun Byte.toHex(): String = String.format("%02X", this)
fun UByte.toHex(): String = this.toByte().toHex()
val Byte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toShort()
val Byte.lowerNibble get() = (this.toInt() and 0b1111).toShort()
val UByte.upperNibble get() = (this.toInt() shr 4 and 0b1111).toUShort()
val UByte.lowerNibble get() = (this.toInt() and 0b1111).toUShort()
fun Byte.isBitSet(pos: Int): Boolean = BitSet.valueOf(arrayOf(this).toByteArray()).get(pos)
fun UByte.isBitSet(pos: Int): Boolean = this.toByte().isBitSet(pos)
fun Short.isBitSet(pos: Int): Boolean = this.toByte().isBitSet(pos)
fun UShort.isBitSet(pos: Int): Boolean = this.toShort().isBitSet(pos)
fun UShort.toBinaryString(): String = Integer.toBinaryString(this.toInt()).padStart(4, '0')
fun UByte.toBinaryString(): String = Integer.toBinaryString(this.toInt()).padStart(8, '0')
@@ -1,51 +0,0 @@
package eu.darken.capod.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.capod.R
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.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")
}
}
@@ -1,13 +0,0 @@
package eu.darken.capod.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
}
@@ -1,40 +0,0 @@
package eu.darken.capod.common
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.res.TypedArray
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
@ColorInt
fun Context.getColorForAttr(@AttrRes attrId: Int): Int {
var typedArray: TypedArray? = null
try {
typedArray = this.theme.obtainStyledAttributes(intArrayOf(attrId))
return typedArray.getColor(0, 0)
} finally {
typedArray?.recycle()
}
}
@ColorInt
fun Fragment.getColorForAttr(@AttrRes attrId: Int): Int = requireContext().getColorForAttr(attrId)
@ColorInt
fun Context.getCompatColor(@ColorRes attrId: Int): Int {
return ContextCompat.getColor(this, attrId)
}
@ColorInt
fun Fragment.getCompatColor(@ColorRes attrId: Int): Int = requireContext().getCompatColor(attrId)
@SuppressLint("NewApi")
fun Context.startServiceCompat(intent: Intent): ComponentName? {
return if (hasApiLevel(26)) startForegroundService(intent) else startService(intent)
}
@@ -1,33 +0,0 @@
package eu.darken.capod.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<String>,
val subject: String,
val body: String
)
}
@@ -1,42 +0,0 @@
package eu.darken.capod.common
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.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"
}
}
@@ -1,23 +0,0 @@
package eu.darken.capod.common
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.viewbinding.ViewBinding
fun <T> LiveData<T>.observe2(fragment: Fragment, callback: (T) -> Unit) {
observe(fragment.viewLifecycleOwner) { callback.invoke(it) }
}
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
fragment: Fragment,
ui: VB,
crossinline callback: VB.(T) -> Unit
) {
observe(fragment.viewLifecycleOwner) { callback.invoke(ui, it) }
}
fun <T> LiveData<T>.observe2(activity: AppCompatActivity, callback: (T) -> Unit) {
observe(activity) { callback.invoke(it) }
}
@@ -1,59 +0,0 @@
package eu.darken.capod.common
import android.media.AudioManager
import android.os.SystemClock
import android.view.KeyEvent
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.delay
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MediaControl @Inject constructor(
private val audioManager: AudioManager,
) {
val isPlaying: Boolean
get() = audioManager.isMusicActive
suspend fun sendPlay() {
log(TAG, INFO) { "sendPlay()" }
if (audioManager.isMusicActive) {
log(TAG, INFO) { "Music is already playing, not sending play" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
}
suspend fun sendPause() {
log(TAG, INFO) { "sendPause()" }
if (!audioManager.isMusicActive) {
log(TAG, INFO) { "Music is not playing, not sending pause" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
}
suspend fun sendPlayPause() {
log(TAG) { "sendPlayPause()" }
if (audioManager.isMusicActive) {
sendPause()
} else {
sendPlay()
}
}
private suspend fun sendKey(keyCode: Int) {
log(TAG) { "Sending up+down KeyEvent: $keyCode" }
val eventTime = SystemClock.uptimeMillis()
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0))
delay(100)
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime + 200, eventTime + 200, KeyEvent.ACTION_UP, keyCode, 0))
}
companion object {
private val TAG = logTag("MediaControl")
}
}
@@ -1,5 +0,0 @@
package eu.darken.capod.common
object PrivacyPolicy {
const val URL = "https://raw.githubusercontent.com/d4rken-org/capod/main/PRIVACY_POLICY.md"
}
@@ -1,9 +0,0 @@
package eu.darken.capod.common
import android.os.SystemClock
object SystemClockWrap {
val elapsedRealtimeNanos: Long
get() = SystemClock.elapsedRealtimeNanos()
}
@@ -1,22 +0,0 @@
package eu.darken.capod.common
import android.content.Context
import android.util.TypedValue
object UIConverter {
fun convertDpToPixels(context: Context, dp: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp,
context.resources.displayMetrics
).toInt()
}
fun convertSpToPixels(context: Context, sp: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
sp,
context.resources.displayMetrics
).toInt()
}
}
@@ -1,28 +0,0 @@
package eu.darken.capod.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.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.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" }
}
}
}
@@ -1,38 +0,0 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.le.ScanResult
import android.os.Parcelable
import androidx.core.util.forEach
import kotlinx.parcelize.Parcelize
@Parcelize
data class BleScanResult(
val address: String,
val rssi: Int,
val generatedAtNanos: Long,
val manufacturerSpecificData: Map<Int, ByteArray>
) : Parcelable {
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
override fun toString(): String {
val sb = StringBuilder()
manufacturerSpecificData.forEach { (key, value) ->
sb.append("$key: ${value.joinToString(separator = " ") { String.format("%02X", it) }}")
}
return "BleScanResult($rssi, $address, $generatedAtNanos, $sb"
}
companion object {
fun fromScanResult(scanResult: ScanResult) = BleScanResult(
address = scanResult.device.address,
rssi = scanResult.rssi,
generatedAtNanos = scanResult.timestampNanos,
manufacturerSpecificData = mutableMapOf<Int, ByteArray>().apply {
scanResult.scanRecord?.manufacturerSpecificData?.forEach { key, value ->
this[key] = value
}
}
)
}
}
@@ -1,152 +0,0 @@
package eu.darken.capod.common.bluetooth
import android.annotation.SuppressLint
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.main.core.ScannerMode
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BleScanner @Inject constructor(
@ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2,
private val fakeBleData: FakeBleData,
) {
@SuppressLint("MissingPermission") fun scan(
filters: Set<ScanFilter>,
scannerMode: ScannerMode,
compatMode: Boolean,
): Flow<List<BleScanResult>> = callbackFlow {
log(TAG, VERBOSE) { "scan(filters=$filters, scannerMode=$scannerMode, compatMode=$compatMode)" }
if (compatMode) log(TAG, WARN) { "Using compatibilityMode!" }
val adapter = bluetoothManager.adapter
val supportsOffloadFiltering = adapter.isOffloadedFilteringSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" }
} && !compatMode
val supportsOffloadBatching = adapter.isOffloadedScanBatchingSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" }
} && !compatMode
val scanner = bluetoothManager.scanner
val callback = object : ScanCallback() {
var lastScanAt = System.currentTimeMillis()
override fun onScanResult(callbackType: Int, result: ScanResult) {
log(TAG, VERBOSE) {
val delay = System.currentTimeMillis() - lastScanAt
lastScanAt = System.currentTimeMillis()
"onScanResult(delay=${delay}ms, callbackType=$callbackType, result=$result)"
}
val toSend = if (
supportsOffloadFiltering
|| filters.isEmpty()
|| filters.any { it.matchesSafe(result) }
) {
listOf(BleScanResult.fromScanResult(result))
} else {
log(TAG, VERBOSE) { "Manual filtering: No match for $result" }
emptyList()
}
trySend(toSend)
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
log(TAG, VERBOSE) {
val delay = System.currentTimeMillis() - lastScanAt
lastScanAt = System.currentTimeMillis()
"onBatchScanResults(delay=${delay}ms, results=$results)"
}
val toSend = results
.filter { result ->
val passed = when {
supportsOffloadFiltering -> true
filters.isEmpty() -> true
else -> filters.any { it.matches(result) }
}
if (!passed) log(TAG, VERBOSE) { "Manually filtered $result" }
passed
}
.map { BleScanResult.fromScanResult(it) }
trySend(toSend)
}
override fun onScanFailed(errorCode: Int) {
log(TAG, WARN) { "onScanFailed(errorCode=$errorCode)" }
}
}
val settings = ScanSettings.Builder().apply {
setScanMode(
when (scannerMode) {
ScannerMode.LOW_POWER -> ScanSettings.SCAN_MODE_LOW_POWER
ScannerMode.BALANCED -> ScanSettings.SCAN_MODE_BALANCED
ScannerMode.LOW_LATENCY -> ScanSettings.SCAN_MODE_LOW_LATENCY
}
)
if (supportsOffloadBatching) {
setReportDelay(
when (scannerMode) {
ScannerMode.LOW_POWER -> 2000L
ScannerMode.BALANCED -> 1000L
ScannerMode.LOW_LATENCY -> 500L
}
)
}
}.build()
log(TAG, VERBOSE) { "Settings created for offloaded filtering: $settings" }
val flushJob = launch {
log(TAG) { "Flush job launched" }
while (isActive) {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
log(TAG, VERBOSE) { "Flushing scan results." }
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(1000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
}
scanner.startScan(
if (supportsOffloadFiltering) filters.toList() else listOf(ScanFilter.Builder().build()),
settings,
callback
)
log(TAG) { "BleScanner started (filters=$filters, settings=$settings)" }
awaitClose {
flushJob.cancel()
scanner.stopScan(callback)
log(TAG) { "BleScanner stopped" }
}
}
.map { fakeBleData.maybeAddfakeData(it) }
companion object {
private val TAG = logTag("Bluetooth", "BleScanner")
}
}
@@ -1,26 +0,0 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.os.ParcelUuid
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
fun BluetoothDevice.hasFeature(uuid: ParcelUuid): Boolean {
return uuids?.contains(uuid) ?: false
}
/**
* java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.Object android.util.SparseArray.get(int)' on a null object reference
* at android.bluetooth.le.ScanRecord.getManufacturerSpecificData(ScanRecord.java:118)
* at android.bluetooth.le.ScanFilter.matches(ScanFilter.java:369)
* ZenFone Max Pro M1 (ZB602KL) (WW) / Max Pro M1 (ZB601KL) (IN) (ZB602KL), Android 9, PKQ1.WW_Phone-16.2017.2009.087-20200826
* Intel Gemini Lake Chromebook (octopus), Android 9, R99-14469.59.0 release-keys
*/
fun ScanFilter.matchesSafe(scanResult: ScanResult): Boolean = try {
matches(scanResult)
} catch (e: NullPointerException) {
log { "AOSP error: ${e.asLog()}" }
false
}
@@ -1,185 +0,0 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.*
import android.bluetooth.le.BluetoothLeScanner
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Handler
import android.os.HandlerThread
import android.os.ParcelUuid
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.io.IOException
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BluetoothManager2 @Inject constructor(
private val manager: BluetoothManager,
@ApplicationContext private val context: Context,
private val dispatcherProvider: DispatcherProvider,
) {
val adapter: BluetoothAdapter
get() = manager.adapter
val scanner: BluetoothLeScanner
get() = adapter.bluetoothLeScanner
?: throw IllegalStateException("Bluetooth is disabled or permissiong missing")
val isBluetoothEnabled: Flow<Boolean> = callbackFlow {
send(manager.adapter?.isEnabled ?: false)
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (BluetoothAdapter.ACTION_STATE_CHANGED != intent.action) {
log(TAG) { "Unknown BluetoothAdapter action: $intent" }
return
}
val value = when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) {
BluetoothAdapter.STATE_OFF -> false
BluetoothAdapter.STATE_ON -> true
else -> false
}
trySend(value)
}
}
context.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
awaitClose { context.unregisterReceiver(receiver) }
}
fun getBluetoothProfile(profile: Int = BluetoothProfile.HEADSET): Flow<BluetoothProfile2> = callbackFlow {
log(TAG, VERBOSE) { "getBluetoothProfile(profile=$profile)" }
var profileProxy: BluetoothProfile2? = null
manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
profileProxy = BluetoothProfile2(
profileType = profile,
profileProxy = proxy,
).also { trySend(it) }
}
override fun onServiceDisconnected(profile: Int) {
log(TAG, WARN) { "onServiceDisconnected(profile=$profile" }
close(IOException("BluetoothProfile service disconnected (profile=$profile)"))
}
}, profile)
awaitClose {
log(TAG) { "Closing BluetoothProfile: $profileProxy" }
profileProxy?.let {
manager.adapter.closeProfileProxy(it.profileType, it.proxy)
}
}
}
private fun monitorDevicesForProfile(
profile: Int = BluetoothProfile.HEADSET
): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
callbackFlow {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
trySend(bluetoothProfile.connectedDevices)
val filter = IntentFilter().apply {
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
}
val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
start()
}
val handler = Handler(handlerThread.looper)
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" }
val action = intent.action
if (action == null) {
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
return
}
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
if (device == null) {
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
return
}
this@callbackFlow.launch {
val currentDevices = bluetoothProfile.connectedDevices
when (action) {
BluetoothDevice.ACTION_ACL_CONNECTED -> {
log(TAG) { "Adding $device to current devices $currentDevices" }
trySend(currentDevices.plus(device))
}
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
log(TAG) { "Removing $device from current devices $currentDevices" }
trySend(currentDevices.minus(device))
}
}
}
}
}
context.registerReceiver(receiver, filter, null, handler)
awaitClose {
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
context.unregisterReceiver(receiver)
}
}
}
fun connectedDevices(
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
): Flow<List<BluetoothDevice>> = isBluetoothEnabled
.flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
.map { devices ->
devices.filter { device ->
featureFilter.any { feature ->
device.hasFeature(feature)
}
}
}
fun bondedDevices(): Set<BluetoothDevice> = adapter.bondedDevices
suspend fun nudgeConnection(device: BluetoothDevice): Boolean = getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
connectMethod.invoke(bluetoothProfile.proxy, device)
log(TAG) { "Nudged connection to $device" }
true
} catch (e: Exception) {
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
false
}
}.first()
companion object {
private val TAG = logTag("Bluetooth", "Manager2")
}
}
@@ -1,16 +0,0 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothProfile
data class BluetoothProfile2(
internal val profileType: Int,
private val profileProxy: BluetoothProfile,
) {
val proxy: BluetoothProfile
get() = profileProxy
val connectedDevices: Set<BluetoothDevice>
get() = proxy.connectedDevices.toSet()
}
@@ -1,116 +0,0 @@
package eu.darken.capod.common.bluetooth
import dagger.Reusable
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.debug.autoreport.DebugSettings
import javax.inject.Inject
import kotlin.random.Random
@Reusable
class FakeBleData @Inject constructor(
private val debugSettings: DebugSettings,
) {
fun maybeAddfakeData(originals: List<BleScanResult>): List<BleScanResult> {
if (!debugSettings.showFakeData.value) return originals
return originals + getFakeData()
}
fun getFakeData(): Collection<BleScanResult> {
val fakeDevices = mutableListOf<BleScanResult>()
// AirPods Gen1
BleScanResult(
address = "78:73:AF:B4:85:22",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// AirPods Gen2
BleScanResult(
address = "78:73:FF:B4:85:5E",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// AirPods Gen3
BleScanResult(
address = "4E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 200,
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// AirPods Max
BleScanResult(
address = "7E:E5:C7:65:D2:B5",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 300,
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// BeatsFlex
BleScanResult(
address = "5E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// Tws i99999
BleScanResult(
address = "5E:9E:D1:29:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
// Unknown Device
BleScanResult(
address = "6E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 500,
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
).run {
if (Random.nextBoolean()) {
fakeDevices.add(this)
}
}
return fakeDevices
}
private fun String.hexToByteArray(): ByteArray {
val trimmed = this
.replace(" ", "")
.replace(">", "")
.replace("<", "")
require(trimmed.length % 2 == 0) { "Not a HEX string" }
return trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
}
@@ -1,5 +0,0 @@
package eu.darken.capod.common.collections
inline fun <K, V> Map<K, V>.mutate(block: MutableMap<K, V>.() -> Unit): Map<K, V> {
return toMutableMap().apply(block).toMap()
}
@@ -1,40 +0,0 @@
package eu.darken.capod.common.compression
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.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<String>, 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
}
}
@@ -1,19 +0,0 @@
package eu.darken.capod.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
@@ -1,19 +0,0 @@
package eu.darken.capod.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
}
@@ -1,7 +0,0 @@
package eu.darken.capod.common.coroutine
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider
@@ -1,21 +0,0 @@
package eu.darken.capod.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
}
@@ -1,42 +0,0 @@
package eu.darken.capod.common.dagger
import android.app.Application
import android.app.NotificationManager
import android.bluetooth.BluetoothManager
import android.content.Context
import android.media.AudioManager
import androidx.work.WorkManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class AndroidModule {
@Provides
@Singleton
fun context(app: Application): Context = app.applicationContext
@Provides
@Singleton
fun notificationManager(context: Context): NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@Provides
@Singleton
fun bluetoothManager(context: Context): BluetoothManager =
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
@Provides
@Singleton
fun workerManager(context: Context): WorkManager =
WorkManager.getInstance(context)
@Provides
@Singleton
fun audioManager(context: Context): AudioManager =
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
@@ -1,27 +0,0 @@
package eu.darken.capod.common.debug
import com.bugsnag.android.Bugsnag
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
object Bugs {
var ready = false
fun report(
tag: String,
message: String,
exception: Throwable
) {
log(TAG, VERBOSE) { "Reporting $exception" }
log(tag, ERROR) { "$message\n${exception.asLog()}" }
if (!ready) {
log(TAG, WARN) { "Bug tracking not initialized yet." }
return
}
Bugsnag.notify(exception)
}
private val TAG = logTag("Bugs")
}
@@ -1,60 +0,0 @@
package eu.darken.capod.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.capod.common.BuildConfigWrap
import eu.darken.capod.common.InstallId
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.autoreport.bugsnag.BugsnagErrorHandler
import eu.darken.capod.common.debug.autoreport.bugsnag.BugsnagLogger
import eu.darken.capod.common.debug.autoreport.bugsnag.NOPBugsnagErrorHandler
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.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 debugSettings: DebugSettings,
private val installId: InstallId,
private val bugsnagLogger: Provider<BugsnagLogger>,
private val bugsnagErrorHandler: Provider<BugsnagErrorHandler>,
private val nopBugsnagErrorHandler: Provider<NOPBugsnagErrorHandler>,
) {
fun setup() {
val isEnabled = debugSettings.isAutoReportingEnabled.value
log(TAG) { "setup(): isEnabled=$isEnabled" }
try {
val bugsnagConfig = Configuration.load(context).apply {
if (debugSettings.isAutoReportingEnabled.value) {
Logging.install(bugsnagLogger.get())
setUser(installId.id, null, null)
autoTrackSessions = true
addOnError(bugsnagErrorHandler.get())
addMetadata("App", "buildFlavor", BuildConfigWrap.FLAVOR)
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", "AutoReport")
}
}
@@ -1,38 +0,0 @@
package eu.darken.capod.common.debug.autoreport
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.preferences.PreferenceStoreMapper
import eu.darken.capod.common.preferences.Settings
import eu.darken.capod.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 = context.getSharedPreferences("settings_debug", Context.MODE_PRIVATE)
val isAutoReportingEnabled = preferences.createFlowPreference(
key = "debug.bugreport.automatic.enabled",
// Reporting is opt-out for gplay, and opt-in for github builds
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
)
val isDebugModeEnabled = preferences.createFlowPreference("debug.mode.enabled", false)
val showFakeData = preferences.createFlowPreference("debug.fakedata.enabled", false)
val showUnfiltered = preferences.createFlowPreference("debug.blescanner.unfiltered.enabled", false)
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
isDebugModeEnabled,
showFakeData,
showUnfiltered,
)
}
@@ -1,59 +0,0 @@
package eu.darken.capod.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.capod.BuildConfig
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.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 && !BuildConfigWrap.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
}
}
}
@@ -1,47 +0,0 @@
package eu.darken.capod.common.debug.autoreport.bugsnag
import com.bugsnag.android.Event
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import java.lang.String.format
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BugsnagLogger @Inject constructor() : Logging.Logger {
// Adding one to the initial size accounts for the add before remove.
private val buffer: Deque<String> = ArrayDeque(BUFFER_SIZE + 1)
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
val line = "${System.currentTimeMillis()} ${priority.toLabel()}/$tag: $message"
synchronized(buffer) {
buffer.addLast(line)
if (buffer.size > BUFFER_SIZE) {
buffer.removeFirst()
}
}
}
fun injectLog(event: Event) {
synchronized(buffer) {
var i = 100
buffer.forEach { event.addMetadata("Log", format(Locale.ROOT, "%03d", i++), it) }
event.addMetadata("Log", format(Locale.ROOT, "%03d", i), event.originalError?.asLog())
}
}
companion object {
private const val BUFFER_SIZE = 200
private fun Logging.Priority.toLabel(): String = when (this) {
Logging.Priority.VERBOSE -> "V"
Logging.Priority.DEBUG -> "D"
Logging.Priority.INFO -> "I"
Logging.Priority.WARN -> "W"
Logging.Priority.ERROR -> "E"
Logging.Priority.ASSERT -> "WTF"
}
}
}
@@ -1,19 +0,0 @@
package eu.darken.capod.common.debug.autoreport.bugsnag
import com.bugsnag.android.Event
import com.bugsnag.android.OnErrorCallback
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.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
}
}
@@ -1,79 +0,0 @@
package eu.darken.capod.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<String, Any>?) {
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")
}
}
@@ -1,49 +0,0 @@
package eu.darken.capod.common.debug.logging
import android.os.Build
import android.util.Log
import kotlin.math.min
class LogCatLogger : Logging.Logger {
override fun isLoggable(priority: Logging.Priority): Boolean = true
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
val trimmedTag = if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) {
tag
} else {
tag.substring(0, MAX_TAG_LENGTH)
}
if (message.length < MAX_LOG_LENGTH) {
writeToLogcat(priority.intValue, trimmedTag, message)
return
}
// Split by line, then ensure each line can fit into Log's maximum length.
var i = 0
val length = message.length
while (i < length) {
var newline = message.indexOf('\n', i)
newline = if (newline != -1) newline else length
do {
val end = min(newline, i + MAX_LOG_LENGTH)
val part = message.substring(i, end)
writeToLogcat(priority.intValue, trimmedTag, part)
i = end
} while (i < newline)
i++
}
}
private fun writeToLogcat(priority: Int, tag: String, part: String) = when (priority) {
Log.ASSERT -> Log.wtf(tag, part)
else -> Log.println(priority, tag, part)
}
companion object {
private const val MAX_LOG_LENGTH = 4000
private const val MAX_TAG_LENGTH = 23
}
}
@@ -1,10 +0,0 @@
package eu.darken.capod.common.debug.logging
fun logTag(vararg tags: String): String {
val sb = StringBuilder("CAP:")
for (i in tags.indices) {
sb.append(tags[i])
if (i < tags.size - 1) sb.append(":")
}
return sb.toString()
}
@@ -1,132 +0,0 @@
package eu.darken.capod.common.debug.logging
import java.io.PrintWriter
import java.io.StringWriter
/**
* Inspired by
* https://github.com/PaulWoitaschek/Slimber
* https://github.com/square/logcat
* https://github.com/JakeWharton/timber
*/
object Logging {
enum class Priority(
val intValue: Int,
val shortLabel: String
) {
VERBOSE(2, "V"),
DEBUG(3, "D"),
INFO(4, "I"),
WARN(5, "W"),
ERROR(6, "E"),
ASSERT(7, "WTF");
}
interface Logger {
fun isLoggable(priority: Priority): Boolean = true
fun log(
priority: Priority,
tag: String,
message: String,
metaData: Map<String, Any>?
)
}
private val internalLoggers = mutableListOf<Logger>()
val loggers: List<Logger>
get() = synchronized(internalLoggers) { internalLoggers.toList() }
val hasReceivers: Boolean
get() = synchronized(internalLoggers) {
internalLoggers.isNotEmpty()
}
fun install(logger: Logger) {
synchronized(internalLoggers) { internalLoggers.add(logger) }
log { "Was installed $logger" }
}
fun remove(logger: Logger) {
log { "Removing: $logger" }
synchronized(internalLoggers) { internalLoggers.remove(logger) }
}
fun logInternal(
tag: String,
priority: Priority,
metaData: Map<String, Any>?,
message: String
) {
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
snapshot
.filter { it.isLoggable(priority) }
.forEach {
it.log(
priority = priority,
tag = tag,
metaData = metaData,
message = message
)
}
}
fun clearAll() {
log { "Clearing all loggers" }
synchronized(internalLoggers) { internalLoggers.clear() }
}
}
inline fun Any.log(
priority: Logging.Priority = Logging.Priority.DEBUG,
metaData: Map<String, Any>? = null,
message: () -> String,
) {
if (Logging.hasReceivers) {
Logging.logInternal(
tag = "CAP:${logTagViaCallSite()}",
priority = priority,
metaData = metaData,
message = message(),
)
}
}
inline fun log(
tag: String,
priority: Logging.Priority = Logging.Priority.DEBUG,
metaData: Map<String, Any>? = null,
message: () -> String,
) {
if (Logging.hasReceivers) {
Logging.logInternal(
tag = tag,
priority = priority,
metaData = metaData,
message = message(),
)
}
}
fun Throwable.asLog(): String {
val stringWriter = StringWriter(256)
val printWriter = PrintWriter(stringWriter, false)
printStackTrace(printWriter)
printWriter.flush()
return stringWriter.toString()
}
@PublishedApi
internal fun Any.logTagViaCallSite(): String {
val javaClass = this::class.java
val fullClassName = javaClass.name
val outerClassName = fullClassName.substringBefore('$')
val simplerOuterClassName = outerClassName.substringAfterLast('.')
return if (simplerOuterClassName.isEmpty()) {
fullClassName
} else {
simplerOuterClassName.removeSuffix("Kt")
}
}
@@ -1,48 +0,0 @@
package eu.darken.capod.common.debug.recording.core
import eu.darken.capod.common.debug.logging.FileLogger
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.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")
}
}
@@ -1,128 +0,0 @@
package eu.darken.capod.common.debug.recording.core
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Environment
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.debug.recording.ui.RecorderActivity
import eu.darken.capod.common.flow.DynamicStateFlow
import eu.darken.capod.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<State> = internalState.flow
init {
internalState.flow
.onEach {
log(TAG) { "New Recorder state: $it" }
internalState.updateBlocking {
if (!isRecording && shouldRecord) {
val newRecorder = Recorder()
newRecorder.start(createRecordingFilePath())
triggerFile.createNewFile()
context.startServiceCompat(Intent(context, RecorderService::class.java))
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION_LONG}" }
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"),
"capod_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 = "capod_force_debug_run"
}
}
@@ -1,115 +0,0 @@
package eu.darken.capod.common.debug.recording.core
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.common.uix.Service2
import eu.darken.capod.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()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIF_CHANID_DEBUG,
getString(R.string.debug_notification_channel_label),
NotificationManager.IMPORTANCE_MIN
)
notificationManager.createNotificationChannel(channel)
}
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
}
}
@@ -1,59 +0,0 @@
package eu.darken.capod.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.capod.common.debug.logging.logTag
import eu.darken.capod.common.error.asErrorDialogBuilder
import eu.darken.capod.common.uix.Activity2
import eu.darken.capod.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
}
}
}
@@ -1,113 +0,0 @@
package eu.darken.capod.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.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.compression.Zipper
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.DynamicStateFlow
import eu.darken.capod.common.flow.onError
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.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<String>(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<Intent>()
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, "CAPod DebugLog - ${BuildConfigWrap.VERSION_DESCRIPTION_LONG})")
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")
}
}
@@ -1,18 +0,0 @@
package eu.darken.capod.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) { _, _ ->
}
}
@@ -1,7 +0,0 @@
package eu.darken.capod.common.error
import eu.darken.capod.common.livedata.SingleLiveEvent
interface ErrorEventSource {
val errorEvents: SingleLiveEvent<Throwable>
}
@@ -1,36 +0,0 @@
package eu.darken.capod.common.error
import android.content.Context
import eu.darken.capod.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")
@@ -1,42 +0,0 @@
package eu.darken.capod.common.error
import java.io.PrintWriter
import java.io.StringWriter
import java.lang.reflect.InvocationTargetException
import kotlin.reflect.KClass
val Throwable.causes: Sequence<Throwable>
get() = sequence {
var subCause = cause
while (subCause != null) {
yield(subCause)
subCause = subCause.cause
}
}
fun Throwable.getRootCause(): Throwable {
var error = this
while (error.cause != null) {
error = error.cause!!
}
if (error is InvocationTargetException) {
error = error.targetException
}
return error
}
fun Throwable.hasCause(exceptionClazz: KClass<out Throwable>): Boolean {
if (exceptionClazz.isInstance(this)) return true
return exceptionClazz.isInstance(this.getRootCause())
}
fun Throwable.getStackTraceString(): String {
val sw = StringWriter(256)
val pw = PrintWriter(sw, false)
printStackTrace(pw)
pw.flush()
return sw.toString()
}
fun Throwable.tryUnwrap(kClass: KClass<RuntimeException> = RuntimeException::class): Throwable =
if (!kClass.isInstance(this)) this else cause ?: this
@@ -1,149 +0,0 @@
package eu.darken.capod.common.flow
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.CoroutineContext
/**
* A thread safe stateful flow that can be updated blocking and async with a lazy initial value provider.
*
* @param loggingTag will be prepended to logging tag, i.e. "$loggingTag:HD"
* @param parentScope on which the update operations and callbacks will be executed on
* @param coroutineContext used in combination with [CoroutineScope]
* @param startValueProvider provides the first value, errors will be rethrown on [CoroutineScope]
*/
class DynamicStateFlow<T>(
loggingTag: String? = null,
parentScope: CoroutineScope,
coroutineContext: CoroutineContext = parentScope.coroutineContext,
private val startValueProvider: suspend CoroutineScope.() -> T
) {
private val lTag = loggingTag?.let { "$it:DSFlow" }
private val updateActions = MutableSharedFlow<Update<T>>(
replay = Int.MAX_VALUE,
extraBufferCapacity = Int.MAX_VALUE,
onBufferOverflow = BufferOverflow.SUSPEND
)
private val valueGuard = Mutex()
private val producer: Flow<State<T>> = channelFlow {
var currentValue = valueGuard.withLock {
lTag?.let { log(it, VERBOSE) { "Providing startValue..." } }
startValueProvider().also { startValue ->
val initializer = Update<T>(onError = null, onModify = { startValue })
send(State(value = startValue, updatedBy = initializer))
lTag?.let { log(it, VERBOSE) { "...startValue provided and emitted." } }
}
}
updateActions.collect { update ->
currentValue = valueGuard.withLock {
try {
update.onModify(currentValue).also {
send(State(value = it, updatedBy = update))
}
} catch (e: Exception) {
lTag?.let {
log(it, VERBOSE) { "Data modifying failed (onError=${update.onError}): ${e.asLog()}" }
}
if (update.onError != null) {
update.onError.invoke(e)
} else {
send(State(value = currentValue, error = e, updatedBy = update))
}
currentValue
}
}
}
lTag?.let { log(it, VERBOSE) { "internal channelFlow finished." } }
}
private val internalFlow = producer
.onStart { lTag?.let { log(it, VERBOSE) { "Internal onStart" } } }
.onCompletion { err ->
when {
err is CancellationException -> {
lTag?.let { log(it, VERBOSE) { "internal onCompletion() due to cancellation" } }
}
err != null -> {
lTag?.let { log(it, VERBOSE) { "internal onCompletion() due to error: ${err.asLog()}" } }
}
else -> {
lTag?.let { log(it, VERBOSE) { "internal onCompletion()" } }
}
}
}
.shareIn(
scope = parentScope + coroutineContext,
replay = 1,
started = SharingStarted.Lazily
)
val flow: Flow<T> = internalFlow
.map { it.value }
.distinctUntilChanged()
suspend fun value() = flow.first()
/**
* Non blocking update method.
* Gets executed on the scope and context this instance was initialized with.
*
* @param onError if you don't provide this, and exception in [onUpdate] will the scope passed to this class
*/
fun updateAsync(
onError: (suspend (Exception) -> Unit) = { throw it },
onUpdate: suspend T.() -> T,
) {
val update: Update<T> = Update(
onModify = onUpdate,
onError = onError
)
runBlocking { updateActions.emit(update) }
}
/**
* Blocking update method
* Gets executed on the scope and context this instance was initialized with.
* Waiting will happen on the callers scope.
*
* Any errors that occurred during [action] will be rethrown by this method.
*/
suspend fun updateBlocking(action: suspend T.() -> T): T {
val update: Update<T> = Update(onModify = action)
updateActions.emit(update)
lTag?.let { log(it, VERBOSE) { "Waiting for update." } }
val ourUpdate = internalFlow.first { it.updatedBy == update }
lTag?.let { log(it, VERBOSE) { "Finished waiting, got $ourUpdate" } }
ourUpdate.error?.let { throw it }
return ourUpdate.value
}
private data class Update<T>(
val onModify: suspend T.() -> T,
val onError: (suspend (Exception) -> Unit)? = null,
)
private data class State<T>(
val value: T,
val error: Exception? = null,
val updatedBy: Update<T>,
)
}
@@ -1,3 +0,0 @@
package eu.darken.capod.common.flow
@@ -1,213 +0,0 @@
package eu.darken.capod.common.flow
import kotlinx.coroutines.flow.Flow
//@Suppress("UNCHECKED_CAST", "LongParameterList")
//inline fun <T1, T2, R> combine(
// flow: Flow<T1>,
// flow2: Flow<T2>,
// crossinline transform: suspend (T1, T2) -> R
//): Flow<R> = kotlinx.coroutines.flow.combine(
// flow,
// flow2
//) { args: Array<*> ->
// transform(
// args[0] as T1,
// args[1] as T2
// )
//}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
crossinline transform: suspend (T1, T2, T3) -> R
): Flow<R> = kotlinx.coroutines.flow.combine(
flow,
flow2,
flow3,
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
crossinline transform: suspend (T1, T2, T3, T4, T5) -> R
): Flow<R> = kotlinx.coroutines.flow.combine(
flow,
flow2,
flow3,
flow4,
flow5
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R
): Flow<R> = kotlinx.coroutines.flow.combine(
flow,
flow2,
flow3,
flow4,
flow5,
flow6
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R
): Flow<R> = kotlinx.coroutines.flow.combine(
flow,
flow2,
flow3,
flow4,
flow5,
flow6,
flow7
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
args[6] as T7
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, T6, T7, T8, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
flow8: Flow<T8>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R
): Flow<R> = kotlinx.coroutines.flow.combine(
flow,
flow2,
flow3,
flow4,
flow5,
flow6,
flow7,
flow8
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
args[6] as T7,
args[7] as T8
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
flow8: Flow<T8>,
flow9: Flow<T9>,
flow10: Flow<T10>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10) -> R
): Flow<R> = kotlinx.coroutines.flow.combine(
flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9, flow10
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
args[6] as T7,
args[7] as T8,
args[8] as T9,
args[9] as T10
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
flow8: Flow<T8>,
flow9: Flow<T9>,
flow10: Flow<T10>,
flow11: Flow<T11>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11) -> R
): Flow<R> = kotlinx.coroutines.flow.combine(
flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9, flow10, flow11
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
args[6] as T7,
args[7] as T8,
args[8] as T9,
args[9] as T10,
args[10] as T11
)
}
@@ -1,74 +0,0 @@
package eu.darken.capod.common.flow
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.error.hasCause
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlin.time.Duration
/**
* Create a stateful flow, with the initial value of null, but never emits a null value.
* Helper method to create a new flow without suspending and without initial value
* The flow collector will just wait for the first value
*/
fun <T : Any> Flow<T>.shareLatest(
tag: String? = null,
scope: CoroutineScope,
started: SharingStarted = SharingStarted.WhileSubscribed(replayExpirationMillis = 0)
) = this
.onStart { if (tag != null) log(tag) { "shareLatest(...) start" } }
.onEach { if (tag != null) log(tag) { "shareLatest(...) emission: $it" } }
.onCompletion { if (tag != null) log(tag) { "shareLatest(...) completed." } }
.catch {
if (tag != null) log(tag) { "shareLatest(...) catch(): ${it.asLog()}" }
throw it
}
.stateIn(
scope = scope,
started = started,
initialValue = null
)
.filterNotNull()
fun <T : Any?> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
scope = scope,
replay = 1,
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO)
)
internal fun <T> Flow<T>.withPrevious(): Flow<Pair<T?, T>> = this
.scan(Pair<T?, T?>(null, null)) { previous, current -> Pair(previous.second, current) }
.drop(1)
.map {
@Suppress("UNCHECKED_CAST")
it as Pair<T?, T>
}
fun <T> Flow<T>.onError(block: suspend (Throwable) -> Unit) = this.catch {
block(it)
throw it
}
fun <T> Flow<T>.takeUntilAfter(predicate: suspend (T) -> Boolean) = transformWhile {
val fullfilled = predicate(it)
emit(it)
!fullfilled // We keep emitting until condition is fullfilled = true
}
fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String) = this
.onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } }
.onEach { log(tag, VERBOSE) { "${identifier()}.onEach(): $it" } }
.onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } }
.catch {
if (it.hasCause(CancellationException::class)) {
log(tag, VERBOSE) { "${identifier()} cancelled" }
} else {
log(tag, ERROR) { "${identifier()} failed: ${it.asLog()}" }
throw it
}
}
@@ -1,58 +0,0 @@
package eu.darken.capod.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.capod.common.getColorForAttr
abstract class BaseAdapter<T : BaseAdapter.VH> : RecyclerView.Adapter<T>() {
@CallSuper
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
return onCreateBaseVH(parent, viewType)
}
abstract fun onCreateBaseVH(parent: ViewGroup, viewType: Int): T
@CallSuper
final override fun onBindViewHolder(holder: T, position: Int) {
onBindBaseVH(holder, position, mutableListOf())
}
@CallSuper
final override fun onBindViewHolder(holder: T, position: Int, payloads: MutableList<Any>) {
onBindBaseVH(holder, position, payloads)
}
abstract fun onBindBaseVH(holder: T, position: Int, payloads: MutableList<Any> = mutableListOf())
abstract class VH(@LayoutRes layoutRes: Int, private val parent: ViewGroup) : RecyclerView.ViewHolder(
LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
) {
val context: Context
get() = parent.context
val resources: Resources
get() = context.resources
val layoutInflater: LayoutInflater
get() = LayoutInflater.from(context)
fun getColor(@ColorRes colorRes: Int): Int = ContextCompat.getColor(context, colorRes)
fun getColorForAttr(@AttrRes attrRes: Int): Int = context.getColorForAttr(attrRes)
fun getString(@StringRes stringRes: Int, vararg args: Any): String = context.getString(stringRes, *args)
fun getQuantityString(@PluralsRes pluralRes: Int, quantity: Int, vararg args: Any): String =
context.resources.getQuantityString(pluralRes, quantity, *args)
fun getQuantityString(@PluralsRes pluralRes: Int, quantity: Int): String =
context.resources.getQuantityString(pluralRes, quantity, quantity)
}
}
@@ -1,26 +0,0 @@
package eu.darken.capod.common.lists
import androidx.viewbinding.ViewBinding
interface BindableVH<ItemT, ViewBindingT : ViewBinding> {
val viewBinding: Lazy<ViewBindingT>
val onBindData: ViewBindingT.(item: ItemT, payloads: List<Any>) -> Unit
fun bind(item: ItemT, payloads: MutableList<Any> = mutableListOf()) = with(viewBinding.value) {
onBindData(item, payloads)
}
}
@Suppress("unused")
inline fun <reified ItemT, ViewBindingT : ViewBinding> BindableVH<ItemT, ViewBindingT>.binding(
payload: Boolean = true,
crossinline block: ViewBindingT.(ItemT) -> Unit,
): ViewBindingT.(item: ItemT, payloads: List<Any>) -> Unit = { item: ItemT, payloads: List<Any> ->
val newestItem = when (payload) {
true -> payloads.filterIsInstance<ItemT>().lastOrNull() ?: item
false -> item
}
block(newestItem)
}
@@ -1,13 +0,0 @@
package eu.darken.capod.common.lists
import androidx.recyclerview.widget.RecyclerView
interface DataAdapter<T> {
val data: MutableList<T>
}
fun <X, T> X.update(newData: List<T>?, notify: Boolean = true) where X : DataAdapter<T>, X : RecyclerView.Adapter<*> {
data.clear()
if (newData != null) data.addAll(newData)
if (notify) notifyDataSetChanged()
}
@@ -1,3 +0,0 @@
package eu.darken.capod.common.lists
interface ListItem
@@ -1,13 +0,0 @@
package eu.darken.capod.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
}
@@ -1,43 +0,0 @@
package eu.darken.capod.common.lists.differ
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.common.lists.modular.mods.StableIdMod
class AsyncDiffer<A, T : DifferItem> internal constructor(
adapter: A,
compareItem: (T, T) -> Boolean = { i1, i2 -> i1.stableId == i2.stableId },
compareItemContent: (T, T) -> Boolean = { i1, i2 -> i1 == i2 },
determinePayload: (T, T) -> Any? = { i1, i2 ->
when {
i1::class == i2::class -> i1.payloadProvider?.invoke(i1, i2)
else -> null
}
}
) where A : HasAsyncDiffer<T>, A : ModularAdapter<*> {
private val callback = object : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = compareItem(oldItem, newItem)
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = compareItemContent(oldItem, newItem)
override fun getChangePayload(oldItem: T, newItem: T): Any? = determinePayload(oldItem, newItem)
}
private val internalList = mutableListOf<T>()
private val listDiffer = AsyncListDiffer(adapter, callback)
val currentList: List<T>
get() = synchronized(internalList) { internalList }
init {
adapter.modules.add(0, StableIdMod(currentList))
}
fun submitUpdate(newData: List<T>) {
listDiffer.submitList(newData) {
synchronized(internalList) {
internalList.clear()
internalList.addAll(newData)
}
}
}
}
@@ -1,15 +0,0 @@
package eu.darken.capod.common.lists.differ
import androidx.recyclerview.widget.RecyclerView
import eu.darken.capod.common.lists.modular.ModularAdapter
fun <X, T> X.update(newData: List<T>?)
where X : HasAsyncDiffer<T>, X : RecyclerView.Adapter<*> {
asyncDiffer.submitUpdate(newData ?: emptyList())
}
fun <A, T : DifferItem> A.setupDiffer(): AsyncDiffer<A, T>
where A : HasAsyncDiffer<T>, A : ModularAdapter<*> =
AsyncDiffer(this)
@@ -1,10 +0,0 @@
package eu.darken.capod.common.lists.differ
import eu.darken.capod.common.lists.ListItem
interface DifferItem : ListItem {
val stableId: Long
val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)?
get() = null
}
@@ -1,10 +0,0 @@
package eu.darken.capod.common.lists.differ
interface HasAsyncDiffer<T : DifferItem> {
val data: List<T>
get() = asyncDiffer.currentList
val asyncDiffer: AsyncDiffer<*, T>
}
@@ -1,95 +0,0 @@
package eu.darken.capod.common.lists.modular
import android.view.ViewGroup
import androidx.annotation.CallSuper
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.RecyclerView
import eu.darken.capod.common.lists.BaseAdapter
abstract class ModularAdapter<VH : ModularAdapter.VH> : BaseAdapter<VH>() {
val modules = mutableListOf<Module>()
init {
modules.filterIsInstance<Module.Setup>().forEach { it.onAdapterReady(this) }
}
override fun getItemId(position: Int): Long {
modules.filterIsInstance<Module.ItemId>().forEach {
val id = it.getItemId(this, position)
if (id != null) return id
}
return super.getItemId(position)
}
@CallSuper
override fun getItemViewType(position: Int): Int {
modules.filterIsInstance<Module.Typing>().forEach {
val type = it.onGetItemType(this, position)
if (type != null) return type
}
return super.getItemViewType(position)
}
override fun onCreateBaseVH(parent: ViewGroup, viewType: Int): VH {
modules.filterIsInstance<Module.Creator<VH>>().forEach {
val vh = it.onCreateModularVH(this, parent, viewType)
if (vh != null) return vh
}
throw IllegalStateException("Couldn't create VH for type $viewType with $parent")
}
@CallSuper
override fun onBindBaseVH(holder: VH, position: Int, payloads: MutableList<Any>) {
modules.filterIsInstance<Module.Binder<VH>>().forEach {
it.onBindModularVH(this, holder, position, payloads)
it.onPostBind(this, holder, position)
}
}
@CallSuper
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
modules.filterIsInstance<Module.RecyclerViewLifecycle>().forEach { it.onAttachedToRecyclerView(recyclerView) }
super.onAttachedToRecyclerView(recyclerView)
}
@CallSuper
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
modules.filterIsInstance<Module.RecyclerViewLifecycle>().forEach { it.onDetachedFromRecyclerView(recyclerView) }
super.onDetachedFromRecyclerView(recyclerView)
}
abstract class VH(@LayoutRes layoutRes: Int, parent: ViewGroup) : BaseAdapter.VH(layoutRes, parent)
interface Module {
interface Setup {
fun onAdapterReady(adapter: ModularAdapter<*>)
}
interface Creator<T : VH> : Module {
fun onCreateModularVH(adapter: ModularAdapter<T>, parent: ViewGroup, viewType: Int): T?
}
interface Binder<T : VH> : Module {
fun onBindModularVH(adapter: ModularAdapter<T>, vh: T, pos: Int, payloads: MutableList<Any>) {
// NOOP
}
fun onPostBind(adapter: ModularAdapter<T>, vh: T, pos: Int) {
// NOOP
}
}
interface Typing : Module {
fun onGetItemType(adapter: ModularAdapter<*>, pos: Int): Int?
}
interface ItemId : Module {
fun getItemId(adapter: ModularAdapter<*>, position: Int): Long?
}
interface RecyclerViewLifecycle : Module {
fun onDetachedFromRecyclerView(recyclerView: RecyclerView)
fun onAttachedToRecyclerView(recyclerView: RecyclerView)
}
}
}
@@ -1,12 +0,0 @@
package eu.darken.capod.common.lists.modular.mods
import eu.darken.capod.common.lists.modular.ModularAdapter
class ClickMod<VHT : ModularAdapter.VH> constructor(
private val listener: (VHT, Int) -> Unit
) : ModularAdapter.Module.Binder<VHT> {
override fun onBindModularVH(adapter: ModularAdapter<VHT>, vh: VHT, pos: Int, payloads: MutableList<Any>) {
vh.itemView.setOnClickListener { listener.invoke(vh, pos) }
}
}
@@ -1,17 +0,0 @@
package eu.darken.capod.common.lists.modular.mods
import androidx.viewbinding.ViewBinding
import eu.darken.capod.common.lists.BindableVH
import eu.darken.capod.common.lists.modular.ModularAdapter
class DataBinderMod<ItemT, HolderT> constructor(
private val data: List<ItemT>,
private val customBinder: (
(adapter: ModularAdapter<HolderT>, vh: HolderT, pos: Int, payload: MutableList<Any>) -> Unit
)? = null
) : ModularAdapter.Module.Binder<HolderT> where HolderT : BindableVH<ItemT, ViewBinding>, HolderT : ModularAdapter.VH {
override fun onBindModularVH(adapter: ModularAdapter<HolderT>, vh: HolderT, pos: Int, payloads: MutableList<Any>) {
customBinder?.invoke(adapter, vh, pos, mutableListOf()) ?: vh.bind(data[pos], payloads)
}
}
@@ -1,15 +0,0 @@
package eu.darken.capod.common.lists.modular.mods
import android.view.ViewGroup
import eu.darken.capod.common.lists.modular.ModularAdapter
class SimpleVHCreatorMod<HolderT> constructor(
private val viewType: Int = 0,
private val factory: (ViewGroup) -> HolderT
) : ModularAdapter.Module.Creator<HolderT> where HolderT : ModularAdapter.VH {
override fun onCreateModularVH(adapter: ModularAdapter<HolderT>, parent: ViewGroup, viewType: Int): HolderT? {
if (this.viewType != viewType) return null
return factory.invoke(parent)
}
}
@@ -1,21 +0,0 @@
package eu.darken.capod.common.lists.modular.mods
import androidx.recyclerview.widget.RecyclerView
import eu.darken.capod.common.lists.differ.DifferItem
import eu.darken.capod.common.lists.modular.ModularAdapter
class StableIdMod<ItemT : DifferItem> constructor(
private val data: List<ItemT>,
private val customResolver: (position: Int) -> Long = {
(data[it] as? DifferItem)?.stableId ?: RecyclerView.NO_ID
}
) : ModularAdapter.Module.ItemId, ModularAdapter.Module.Setup {
override fun onAdapterReady(adapter: ModularAdapter<*>) {
adapter.setHasStableIds(true)
}
override fun getItemId(adapter: ModularAdapter<*>, position: Int): Long? {
return customResolver.invoke(position)
}
}
@@ -1,29 +0,0 @@
package eu.darken.capod.common.lists.modular.mods
import android.view.ViewGroup
import eu.darken.capod.common.lists.modular.ModularAdapter
class TypedVHCreatorMod<HolderT> constructor(
private val typeResolver: (Int) -> Boolean,
private val factory: (ViewGroup) -> HolderT
) : ModularAdapter.Module.Typing,
ModularAdapter.Module.Creator<HolderT> where HolderT : ModularAdapter.VH {
private fun ModularAdapter<*>.determineOurViewType(): Int {
val typingModules = modules.filterIsInstance(ModularAdapter.Module.Typing::class.java)
return typingModules.indexOf(this@TypedVHCreatorMod)
}
override fun onGetItemType(adapter: ModularAdapter<*>, pos: Int): Int? {
return if (typeResolver.invoke(pos)) adapter.determineOurViewType() else null
}
override fun onCreateModularVH(
adapter: ModularAdapter<HolderT>,
parent: ViewGroup,
viewType: Int
): HolderT? {
if (adapter.determineOurViewType() != viewType) return null
return factory.invoke(parent)
}
}
@@ -1,76 +0,0 @@
package eu.darken.capod.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.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import java.util.concurrent.atomic.AtomicBoolean
/**
* A lifecycle-aware observable that sends only new updates after subscription, used for events like
* navigation and Snackbar messages.
*
*
* This avoids a common problem with events: on configuration change (like rotation) an update
* can be emitted if the observer is active. This LiveData only calls the observable if there's an
* explicit call to setValue() or call().
*
*
* Note that only one observer is going to be notified of changes.
* https://github.com/android/architecture-samples/blob/166ca3a93ad14c6e224a3ea9bfcbd773eb048fb0/todoapp/app/src/main/java/com/example/android/architecture/blueprints/todoapp/SingleLiveEvent.java
*/
class SingleLiveEvent<T> : MutableLiveData<T>() {
private val pending = AtomicBoolean(false)
@MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
if (hasActiveObservers()) {
log(WARN) { "Multiple observers registered but only one will be notified of changes." }
}
// Observe the internal MutableLiveData
super.observe(
owner,
{ t ->
if (pending.compareAndSet(true, false)) {
observer.onChanged(t)
}
}
)
}
@MainThread
override fun setValue(@Nullable t: T?) {
pending.set(true)
super.setValue(t)
}
/**
* Used for cases where T is Void, to make calls cleaner.
*/
@MainThread
fun call() {
value = null
}
}
@@ -1,38 +0,0 @@
package eu.darken.capod.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.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
fun Fragment.doNavigate(direction: NavDirections) = findNavController().doNavigate(direction)
fun Fragment.popBackStack(): Boolean {
if (!isAdded) {
IllegalStateException("Fragment is not added").also {
log(WARN) { "Trying to pop backstack on Fragment that isn't added to an Activity: ${it.asLog()}" }
}
return false
}
return findNavController().popBackStack()
}
/**
* [FragmentContainerView] does not access [NavController] in [Activity.onCreate]
* as workaround [FragmentManager] is used to get the [NavController]
* @param id [Int] NavFragment id
* @see <a href="https://issuetracker.google.com/issues/142847973">issue-142847973</a>
*/
@Throws(IllegalStateException::class)
fun FragmentManager.findNavController(@IdRes id: Int): NavController {
val fragment = findFragmentById(id) ?: throw IllegalStateException("Fragment is not found for id:$id")
return NavHostFragment.findNavController(fragment)
}
@@ -1,20 +0,0 @@
package eu.darken.capod.common.navigation
import android.os.Bundle
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavArgs
import androidx.navigation.NavArgsLazy
import java.io.Serializable
// TODO Remove with "androidx.navigation:navigation-safe-args-gradle-plugin:2.4.0-alpha/stable"
inline fun <reified Args : NavArgs> SavedStateHandle.navArgs() = NavArgsLazy(Args::class) {
Bundle().apply {
keys().forEach {
when (val value = get<Any>(it)) {
is Serializable -> putSerializable(it, value)
is Parcelable -> putParcelable(it, value)
}
}
}
}
@@ -1,22 +0,0 @@
package eu.darken.capod.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
}
@@ -1,9 +0,0 @@
package eu.darken.capod.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
}
@@ -1,13 +0,0 @@
package eu.darken.capod.common.navigation
import androidx.lifecycle.MutableLiveData
import androidx.navigation.NavDirections
import eu.darken.capod.common.livedata.SingleLiveEvent
fun NavDirections.navVia(pub: MutableLiveData<in NavDirections>) = pub.postValue(this)
fun NavDirections.navVia(provider: NavEventSource) = this.navVia(provider.navEvents)
interface NavEventSource {
val navEvents: SingleLiveEvent<in NavDirections>
}
@@ -1,12 +0,0 @@
package eu.darken.capod.common.notifications
import android.app.PendingIntent
import eu.darken.capod.common.hasApiLevel
object PendingIntentCompat {
val FLAG_IMMUTABLE: Int = if (hasApiLevel(31)) {
PendingIntent.FLAG_IMMUTABLE
} else {
0
}
}
@@ -1,80 +0,0 @@
package eu.darken.capod.common.permissions
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.PowerManager
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.withinApiLevel
enum class Permission(
val minApiLevel: Int,
val maxApiLevel: Int = Int.MAX_VALUE,
@StringRes val labelRes: Int,
@StringRes val descriptionRes: Int,
val permissionId: String,
val isGranted: (Context) -> Boolean = {
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
},
) {
BLUETOOTH(
minApiLevel = Build.VERSION_CODES.BASE,
maxApiLevel = Build.VERSION_CODES.R,
labelRes = R.string.permission_bluetooth_label,
descriptionRes = R.string.permission_bluetooth_description,
permissionId = "android.permission.BLUETOOTH",
),
BLUETOOTH_CONNECT(
minApiLevel = Build.VERSION_CODES.S,
labelRes = R.string.permission_bluetooth_connect_label,
descriptionRes = R.string.permission_bluetooth_connect_description,
permissionId = "android.permission.BLUETOOTH_CONNECT",
),
BLUETOOTH_SCAN(
minApiLevel = Build.VERSION_CODES.S,
labelRes = R.string.permission_bluetooth_scan_label,
descriptionRes = R.string.permission_bluetooth_scan_description,
permissionId = "android.permission.BLUETOOTH_SCAN",
),
ACCESS_FINE_LOCATION(
minApiLevel = Build.VERSION_CODES.BASE,
maxApiLevel = Build.VERSION_CODES.R,
labelRes = R.string.permission_access_fine_location_label,
descriptionRes = R.string.permission_access_fine_location_description,
permissionId = "android.permission.ACCESS_FINE_LOCATION",
),
ACCESS_BACKGROUND_LOCATION(
minApiLevel = Build.VERSION_CODES.Q,
maxApiLevel = Build.VERSION_CODES.R,
labelRes = R.string.permission_background_location_label,
descriptionRes = R.string.permission_background_location_description,
permissionId = "android.permission.ACCESS_BACKGROUND_LOCATION",
),
IGNORE_BATTERY_OPTIMIZATION(
minApiLevel = Build.VERSION_CODES.BASE,
labelRes = R.string.permission_ignore_battery_optimizations_label,
descriptionRes = R.string.permission_ignore_battery_optimizations_description,
permissionId = "android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS",
isGranted = {
val pwm = it.getSystemService(Context.POWER_SERVICE) as PowerManager
pwm.isIgnoringBatteryOptimizations(BuildConfigWrap.APPLICATION_ID)
},
),
SYSTEM_ALERT_WINDOW(
minApiLevel = Build.VERSION_CODES.BASE,
labelRes = R.string.permission_system_alert_window_label,
descriptionRes = R.string.permission_system_alert_window_description,
permissionId = "android.permission.SYSTEM_ALERT_WINDOW",
isGranted = {
android.provider.Settings.canDrawOverlays(it)
},
)
}
fun Permission.isRequired(context: Context): Boolean = when {
!withinApiLevel(minApiLevel, maxApiLevel) -> false
else -> !isGranted(context)
}
@@ -1,63 +0,0 @@
package eu.darken.capod.common.preferences
import android.content.SharedPreferences
import androidx.core.content.edit
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FlowPreference<T> constructor(
private val preferences: SharedPreferences,
val key: String,
val rawReader: (Any?) -> T,
val rawWriter: (T) -> Any?
) {
private val flowInternal = MutableStateFlow(value)
val flow: Flow<T> = 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)
}
}
@@ -1,43 +0,0 @@
package eu.darken.capod.common.preferences
import android.content.SharedPreferences
inline fun <reified T> basicReader(defaultValue: T): (rawValue: Any?) -> T =
{ rawValue ->
(rawValue ?: defaultValue) as T
}
inline fun <reified T> 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 <reified T : Any?> SharedPreferences.createFlowPreference(
key: String,
defaultValue: T = null as T
) = FlowPreference(
preferences = this,
key = key,
rawReader = basicReader(defaultValue),
rawWriter = basicWriter()
)
inline fun <reified T : Any?> SharedPreferences.createFlowPreference(
key: String,
noinline reader: (rawValue: Any?) -> T,
noinline writer: (value: T) -> Any?
) = FlowPreference(
preferences = this,
key = key,
rawReader = reader,
rawWriter = writer
)
@@ -1,35 +0,0 @@
package eu.darken.capod.common.preferences
import android.content.SharedPreferences
import com.squareup.moshi.Moshi
inline fun <reified T> 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 <reified T> moshiWriter(
moshi: Moshi,
): (T) -> Any? {
val adapter = moshi.adapter(T::class.java)
return { newValue: T ->
newValue?.let { adapter.toJson(it) }
}
}
inline fun <reified T : Any?> SharedPreferences.createFlowPreference(
key: String,
defaultValue: T = null as T,
moshi: Moshi,
) = FlowPreference(
preferences = this,
key = key,
rawReader = moshiReader(moshi, defaultValue),
rawWriter = moshiWriter(moshi)
)
@@ -1,94 +0,0 @@
package eu.darken.capod.common.preferences
import android.content.Context
import android.content.res.TypedArray
import android.os.Parcelable
import android.util.AttributeSet
import androidx.annotation.PluralsRes
import androidx.preference.DialogPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import eu.darken.capod.R
import eu.darken.capod.common.preferences.PercentSliderPreferenceDialogFragment.Companion.newInstance
import kotlinx.parcelize.Parcelize
class PercentSliderPreference(context: Context?, attrs: AttributeSet?) : DialogPreference(context, attrs) {
@get:PluralsRes val sliderTextPluralsResource: Int
val min: Float
val max: Float
private var internalValue = 0f
private var internalValueSet = false
init {
val a = getContext().obtainStyledAttributes(attrs, R.styleable.PercentSliderPreference)
min = a.getFloat(R.styleable.PercentSliderPreference_pspMin, 0f)
max = a.getFloat(R.styleable.PercentSliderPreference_pspMax, 1f)
sliderTextPluralsResource = a.getResourceId(R.styleable.PercentSliderPreference_sliderText, 0)
a.recycle()
}
// Always persist/notify the first time.
var value: Float
get() = internalValue
set(value) {
// Always persist/notify the first time.
val changed = internalValue != value
if (changed || !internalValueSet) {
internalValue = value
internalValueSet = true
persistFloat(value)
if (changed) notifyChanged()
}
}
override fun onGetDefaultValue(a: TypedArray, index: Int): Int {
return a.getInteger(index, 0)
}
override fun onSetInitialValue(restoreValue: Boolean, defaultValue: Any?) {
value = if (restoreValue) getPersistedFloat(internalValue) else defaultValue as Float
}
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
// No need to save instance state since it's persistent
if (isPersistent) return superState
return SavedState(value = value, superState = superState)
}
override fun onRestoreInstanceState(state: Parcelable?) {
if (state?.javaClass != SavedState::class.java) {
// Didn't save state for us in onSaveInstanceState
return super.onRestoreInstanceState(state)
}
val myState = state as SavedState
super.onRestoreInstanceState(myState.superState)
value = myState.value
}
@Parcelize
data class SavedState(
val value: Float,
val superState: Parcelable,
) : Parcelable
companion object {
private const val DIALOG_FRAGMENT_TAG = "android.support.v7.preference.PreferenceFragment.DIALOG"
fun onDisplayPreferenceDialog(preferenceFragment: PreferenceFragmentCompat, preference: Preference): Boolean {
if (preference is PercentSliderPreference) {
val fragmentManager = preferenceFragment.fragmentManager
if (fragmentManager!!.findFragmentByTag(DIALOG_FRAGMENT_TAG) == null) {
val dialogFragment = newInstance(preference.getKey())
dialogFragment.setTargetFragment(preferenceFragment, 0)
dialogFragment.show(fragmentManager, DIALOG_FRAGMENT_TAG)
}
return true
}
return false
}
}
}
@@ -1,91 +0,0 @@
package eu.darken.capod.common.preferences
import android.os.Bundle
import android.view.Gravity
import android.widget.LinearLayout
import android.widget.LinearLayout.*
import android.widget.SeekBar
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.preference.PreferenceDialogFragmentCompat
import eu.darken.capod.common.UIConverter
import kotlin.math.roundToInt
class PercentSliderPreferenceDialogFragment : PreferenceDialogFragmentCompat(), SeekBar.OnSeekBarChangeListener {
private val layoutContainer by lazy { LinearLayout(requireContext()) }
private val valueText by lazy { TextView(requireContext()) }
private val splashText by lazy { TextView(requireContext()) }
private val seekBar by lazy { SeekBar(requireContext()) }
private val preferencePercent: PercentSliderPreference
get() = super.getPreference() as PercentSliderPreference
override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) {
layoutContainer.apply {
orientation = VERTICAL
val px: Int = UIConverter.convertDpToPixels(requireContext(), 24f)
setPadding(px, 0, px, 0)
}
splashText.apply {
if (preferencePercent.dialogMessage != null) splashText.text = preferencePercent.dialogMessage
layoutContainer.addView(this)
}
valueText.apply {
gravity = Gravity.CENTER_HORIZONTAL
textSize = 32f
layoutContainer.addView(this, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
}
seekBar.apply {
setOnSeekBarChangeListener(this@PercentSliderPreferenceDialogFragment)
max = (preferencePercent.max * 100).roundToInt()
progress = (preferencePercent.value * 100).roundToInt()
layoutContainer.addView(this, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
}
updateValueText()
builder.setView(layoutContainer)
builder.setNegativeButton(null, null)
super.onPrepareDialogBuilder(builder)
}
override fun onDialogClosed(positiveResult: Boolean) {
if (!positiveResult) return
val value: Int = seekBar.progress
if (preferencePercent.callChangeListener(value)) {
preferencePercent.value = value / 100f
}
}
override fun onProgressChanged(seek: SeekBar, value: Int, fromTouch: Boolean) {
if (value < preferencePercent.min) {
seek.progress = (preferencePercent.min * 100).roundToInt()
}
updateValueText()
}
private fun updateValueText() {
val count: Int = seekBar.progress
valueText.text = if (preferencePercent.sliderTextPluralsResource != 0) {
resources.getQuantityString(preferencePercent.sliderTextPluralsResource, count, count)
} else {
"$count%"
}
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
companion object {
@JvmStatic fun newInstance(key: String): PercentSliderPreferenceDialogFragment {
val fragment = PercentSliderPreferenceDialogFragment()
val arguments = Bundle()
arguments.putString(ARG_KEY, key)
fragment.arguments = arguments
return fragment
}
}
}
@@ -1,81 +0,0 @@
package eu.darken.capod.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? {
val pref = flowPreferences.singleOrNull { it.key == key }
?: throw NotImplementedError("getString(key=$key, defValue=$defValue)")
return pref.let { flowPref ->
flowPref.valueRaw as String?
}
}
override fun putString(key: String, value: String?) {
val pref = flowPreferences.singleOrNull { it.key == key }
?: throw NotImplementedError("putString(key=$key, defValue=$value)")
pref.let { flowPref ->
flowPref.valueRaw = 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<String>?) {
throw NotImplementedError("putStringSet(key=$key, defValue=$values)")
}
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String> {
throw NotImplementedError("getStringSet(key=$key, defValue=$defValues)")
}
}
@@ -1,12 +0,0 @@
package eu.darken.capod.common.preferences
import android.content.SharedPreferences
import androidx.preference.PreferenceDataStore
abstract class Settings {
abstract val preferenceDataStore: PreferenceDataStore
abstract val preferences: SharedPreferences
}
@@ -1,16 +0,0 @@
package eu.darken.capod.common.preferences
import android.content.SharedPreferences
import androidx.core.content.edit
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.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() }
}
@@ -1,13 +0,0 @@
package eu.darken.capod.common.serialization
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import java.time.Instant
class JavaInstantAdapter {
@ToJson
fun toJson(obj: Instant): Long = obj.toEpochMilli()
@FromJson
fun fromJson(epochMillis: Long): Instant = Instant.ofEpochSecond(epochMillis)
}
@@ -1,20 +0,0 @@
package eu.darken.capod.common.serialization
import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class SerializationModule {
@Provides
@Singleton
fun moshi(): Moshi = Moshi.Builder()
.add(JavaInstantAdapter())
.build()
}
@@ -1,44 +0,0 @@
package eu.darken.capod.common.uix
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.LiveData
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.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 <T> LiveData<T>.observe2(callback: (T) -> Unit) {
observe(this@Activity2) { callback.invoke(it) }
}
}
@@ -1,95 +0,0 @@
package eu.darken.capod.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.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.error.asErrorDialogBuilder
import eu.darken.capod.common.navigation.doNavigate
import eu.darken.capod.common.navigation.popBackStack
import eu.darken.capod.common.observe2
abstract class BottomSheetDialogFragment2 : BottomSheetDialogFragment() {
abstract val ui: ViewBinding
abstract val vdc: 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)
vdc.navEvents.observe2(this, ui) { dir -> dir?.let { doNavigate(it) } ?: popBackStack() }
vdc.errorEvents.observe2(this, ui) { it.asErrorDialogBuilder(requireContext()).show() }
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
log(tag, VERBOSE) { "onActivityCreated(savedInstanceState=$savedInstanceState)" }
super.onActivityCreated(savedInstanceState)
}
override fun onResume() {
log(tag, VERBOSE) { "onResume()" }
super.onResume()
}
override fun onPause() {
log(tag, VERBOSE) { "onPause()" }
super.onPause()
}
override fun onDestroyView() {
log(tag, VERBOSE) { "onDestroyView()" }
super.onDestroyView()
}
override fun onDetach() {
log(tag, VERBOSE) { "onDetach()" }
super.onDetach()
}
override fun onDestroy() {
log(tag, VERBOSE) { "onDestroy()" }
super.onDestroy()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
log(tag, VERBOSE) { "onActivityResult(requestCode=$requestCode, resultCode=$resultCode, data=$data)" }
super.onActivityResult(requestCode, resultCode, data)
}
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
ui: VB,
crossinline callback: VB.(T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(ui, it) }
}
}
@@ -1,80 +0,0 @@
package eu.darken.capod.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.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.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)
}
}
@@ -1,53 +0,0 @@
package eu.darken.capod.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.capod.common.debug.logging.log
import eu.darken.capod.common.error.asErrorDialogBuilder
import eu.darken.capod.common.navigation.doNavigate
import eu.darken.capod.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 <T> LiveData<T>.observe2(
crossinline callback: (T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(it) }
}
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
ui: VB,
crossinline callback: VB.(T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(ui, it) }
}
}
@@ -1,85 +0,0 @@
package eu.darken.capod.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.lifecycle.LiveData
import androidx.preference.PreferenceFragmentCompat
import androidx.viewbinding.ViewBinding
import eu.darken.capod.common.preferences.Settings
import eu.darken.capod.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
}
}
}
inline fun <T> LiveData<T>.observe2(
crossinline callback: (T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(it) }
}
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
ui: VB,
crossinline callback: VB.(T) -> Unit
) {
observe(viewLifecycleOwner) { callback.invoke(ui, it) }
}
}
@@ -1,52 +0,0 @@
package eu.darken.capod.common.uix
import android.app.Service
import android.content.Intent
import android.content.res.Configuration
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.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)
}
}
@@ -1,20 +0,0 @@
package eu.darken.capod.common.uix
import androidx.annotation.CallSuper
import androidx.lifecycle.ViewModel
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
abstract class ViewModel1 : ViewModel() {
val TAG: String = logTag("VM", javaClass.simpleName)
init {
log(TAG) { "Initialized" }
}
@CallSuper
override fun onCleared() {
log(TAG) { "onCleared()" }
super.onCleared()
}
}
@@ -1,63 +0,0 @@
package eu.darken.capod.common.uix
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import eu.darken.capod.common.coroutine.DefaultDispatcherProvider
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.error.ErrorEventSource
import eu.darken.capod.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 getVDCContext(): CoroutineContext {
val dispatcher = dispatcherProvider.Default
return getErrorHandler()?.let { dispatcher + it } ?: dispatcher
}
private fun getErrorHandler(): CoroutineExceptionHandler? {
val handler = launchErrorHandler
if (handler != null) return handler
if (this is ErrorEventSource) {
return CoroutineExceptionHandler { _, ex ->
log(WARN) { "Error during launch: ${ex.asLog()}" }
errorEvents.postValue(ex)
}
}
return null
}
fun <T : Any> DynamicStateFlow<T>.asLiveData2() = flow.asLiveData2()
fun <T> Flow<T>.asLiveData2() = this.asLiveData(context = getVDCContext())
fun launch(
scope: CoroutineScope = viewModelScope,
context: CoroutineContext = getVDCContext(),
block: suspend CoroutineScope.() -> Unit
) {
try {
scope.launch(context = context, block = block)
} catch (e: CancellationException) {
log(TAG, WARN) { "launch()ed coroutine was canceled (scope=$scope): ${e.asLog()}" }
}
}
open fun <T> Flow<T>.launchInViewModel() = this.launchIn(vmScope)
}
@@ -1,34 +0,0 @@
package eu.darken.capod.common.uix
import androidx.navigation.NavDirections
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.error.ErrorEventSource
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.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<NavDirections?>()
override val errorEvents = SingleLiveEvent<Throwable>()
init {
launchErrorHandler = CoroutineExceptionHandler { _, ex ->
log(TAG) { "Error during launch: ${ex.asLog()}" }
errorEvents.postValue(ex)
}
}
override fun <T> Flow<T>.launchInViewModel() = this
.setupCommonEventHandlers(TAG) { "launchInViewModel()" }
.launchIn(vmScope)
}
@@ -1,174 +0,0 @@
package eu.darken.capod.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 <reified VM : ViewModel> ViewModelProvider.get() = get(VM::class.java)
/**
* An implementation of [Lazy] used by [androidx.fragment.app.Fragment.viewModels] and
* [androidx.activity.ComponentActivity.viewmodels].
*
* [storeProducer] is a lambda that will be called during initialization, [VM] will be created
* in the scope of returned [ViewModelStore].
*
* [factoryProducer] is a lambda that will be called during initialization,
* returned [ViewModelProvider.Factory] will be used for creation of [VM]
*/
class ViewModelLazyKeyed<VM : ViewModel>(
private val viewModelClass: KClass<VM>,
private val keyProducer: (() -> String)? = null,
private val storeProducer: () -> ViewModelStore,
private val factoryProducer: () -> ViewModelProvider.Factory
) : Lazy<VM> {
private var cached: VM? = null
override val value: VM
get() {
val viewModel = cached
return if (viewModel == null) {
val factory = factoryProducer()
val store = storeProducer()
val key = keyProducer?.invoke() ?: "androidx.lifecycle.ViewModelProvider.DefaultKey"
ViewModelProvider(store, factory).get(
key + ":" + viewModelClass.java.canonicalName,
viewModelClass.java
).also {
cached = it
}
} else {
viewModel
}
}
override fun isInitialized() = cached != null
}
/**
* Returns a property delegate to access [ViewModel] by **default** scoped to this [Fragment]:
* ```
* class MyFragment : Fragment() {
* val viewmodel: NYViewModel by viewmodels()
* }
* ```
*
* Custom [ViewModelProvider.Factory] can be defined via [factoryProducer] parameter,
* factory returned by it will be used to create [ViewModel]:
* ```
* class MyFragment : Fragment() {
* val viewmodel: MYViewModel by viewmodels { myFactory }
* }
* ```
*
* Default scope may be overridden with parameter [ownerProducer]:
* ```
* class MyFragment : Fragment() {
* val viewmodel: MYViewModel by viewmodels ({requireParentFragment()})
* }
* ```
*
* This property can be accessed only after this Fragment is attached i.e., after
* [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException.
*/
@MainThread
inline fun <reified VM : ViewModel> Fragment.viewModelsKeyed(
noinline keyProducer: (() -> String)? = null,
noinline ownerProducer: () -> ViewModelStoreOwner = { this },
noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
) = createViewModelLazyKeyed(VM::class, keyProducer, { ownerProducer().viewModelStore }, factoryProducer)
/**
* Returns a property delegate to access parent activity's [ViewModel],
* if [factoryProducer] is specified then [ViewModelProvider.Factory]
* returned by it will be used to create [ViewModel] first time.
*
* ```
* class MyFragment : Fragment() {
* val viewmodel: MyViewModel by activityViewModels()
* }
* ```
*
* This property can be accessed only after this Fragment is attached i.e., after
* [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException.
*/
@MainThread
inline fun <reified VM : ViewModel> Fragment.activityViewModelsKeyed(
noinline keyProducer: (() -> String)? = null,
noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
) = createViewModelLazyKeyed(VM::class, keyProducer, { requireActivity().viewModelStore }, factoryProducer)
/**
* Helper method for creation of [ViewModelLazy], that resolves `null` passed as [factoryProducer]
* to default factory.
*/
@MainThread
fun <VM : ViewModel> Fragment.createViewModelLazyKeyed(
viewModelClass: KClass<VM>,
keyProducer: (() -> String)? = null,
storeProducer: () -> ViewModelStore,
factoryProducer: (() -> ViewModelProvider.Factory)? = null
): Lazy<VM> {
val factoryPromise = factoryProducer ?: {
val application = activity?.application ?: throw IllegalStateException(
"ViewModel can be accessed only when Fragment is attached"
)
ViewModelProvider.AndroidViewModelFactory.getInstance(application)
}
return ViewModelLazyKeyed(viewModelClass, keyProducer, storeProducer, factoryPromise)
}
/**
* Returns a [Lazy] delegate to access the ComponentActivity's ViewModel, if [factoryProducer]
* is specified then [ViewModelProvider.Factory] returned by it will be used
* to create [ViewModel] first time.
*
* ```
* class MyComponentActivity : ComponentActivity() {
* val viewmodel: MyViewModel by viewmodels()
* }
* ```
*
* This property can be accessed only after the Activity is attached to the Application,
* and access prior to that will result in IllegalArgumentException.
*/
@MainThread
inline fun <reified VM : ViewModel> ComponentActivity.viewModelsKeyed(
noinline keyProducer: (() -> String)? = null,
noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
): Lazy<VM> {
val factoryPromise = factoryProducer ?: {
val application = application ?: throw IllegalArgumentException(
"ViewModel can be accessed only when Activity is attached"
)
ViewModelProvider.AndroidViewModelFactory.getInstance(application)
}
return ViewModelLazyKeyed(VM::class, keyProducer, { viewModelStore }, factoryPromise)
}
@@ -1,24 +0,0 @@
package eu.darken.capod.common.upgrade
import android.app.Activity
import kotlinx.coroutines.flow.Flow
import java.time.Instant
interface UpgradeRepo {
val upgradeInfo: Flow<Info>
fun launchBillingFlow(activity: Activity)
interface Info {
val type: Type
val isPro: Boolean
val upgradedAt: Instant?
}
enum class Type {
GPLAY,
FOSS
}
}
@@ -1,6 +0,0 @@
package eu.darken.capod.common.upgrade
import kotlinx.coroutines.flow.first
suspend fun UpgradeRepo.isPro(): Boolean = upgradeInfo.first().isPro
@@ -1,93 +0,0 @@
package eu.darken.capod.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.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
inline fun <FragmentT : Fragment, reified BindingT : ViewBinding> FragmentT.viewBinding() =
this.viewBinding(
bindingProvider = {
val bindingMethod = BindingT::class.java.getMethod("bind", View::class.java)
bindingMethod(null, requireView()) as BindingT
},
lifecycleOwnerProvider = { viewLifecycleOwner }
)
@Suppress("unused")
fun <FragmentT : Fragment, BindingT : ViewBinding> FragmentT.viewBinding(
bindingProvider: FragmentT.() -> BindingT,
lifecycleOwnerProvider: FragmentT.() -> LifecycleOwner
) = ViewBindingProperty(bindingProvider, lifecycleOwnerProvider)
class ViewBindingProperty<ComponentT : LifecycleOwner, BindingT : ViewBinding>(
private val bindingProvider: (ComponentT) -> BindingT,
private val lifecycleOwnerProvider: ComponentT.() -> LifecycleOwner
) : ReadOnlyProperty<ComponentT, BindingT> {
private val uiHandler = Handler(Looper.getMainLooper())
private var localRef: ComponentT? = null
private var viewBinding: BindingT? = null
private val onDestroyObserver = object : DefaultLifecycleObserver {
// Called right before Fragment.onDestroyView
override fun onDestroy(owner: LifecycleOwner) {
localRef?.lifecycle?.removeObserver(this) ?: return
localRef = null
uiHandler.post {
log(VERBOSE) { "Resetting viewBinding" }
viewBinding = null
}
}
}
@MainThread
override fun getValue(thisRef: ComponentT, property: KProperty<*>): BindingT {
if (localRef == null && viewBinding != null) {
log(WARN) { "Fragment.onDestroyView() was called, but the handler didn't execute our delayed reset." }
/**
* There is a fragment racecondition if you navigate to another fragment and quickly popBackStack().
* Our uiHandler.post { } will not have executed for some reason.
* In that case we manually null the old viewBinding, to allow for clean recreation.
*/
viewBinding = null
}
/**
* When quickly navigating, a fragment may be created that was never visible to the user.
* It's possible that [Fragment.onDestroyView] is called, but [DefaultLifecycleObserver.onDestroy] is not.
* This means the ViewBinding will is not be set to `null` and it still holds the previous layout,
* instead of the new layout that the Fragment inflated when navigating back to it.
*/
(localRef as? Fragment)?.view?.let {
if (it != viewBinding?.root && localRef === thisRef) {
log(WARN) { "Different view for the same fragment, resetting old viewBinding." }
viewBinding = null
}
}
viewBinding?.let {
// Only accessible from within the same component
require(localRef === thisRef)
return@getValue it
}
val lifecycle = lifecycleOwnerProvider(thisRef).lifecycle
return bindingProvider(thisRef).also {
viewBinding = it
localRef = thisRef
lifecycle.addObserver(onDestroyObserver)
}
}
}
@@ -1,31 +0,0 @@
package eu.darken.capod.common.worker
import android.os.Parcel
import android.os.Parcelable
import androidx.work.Data
@Suppress("UNCHECKED_CAST")
inline fun <reified T : Parcelable> Data.getParcelable(key: String): T? {
val parcel = Parcel.obtain()
try {
val bytes = getByteArray(key) ?: return null
parcel.unmarshall(bytes, 0, bytes.size)
parcel.setDataPosition(0)
val creator = T::class.java.getField("CREATOR").get(null) as Parcelable.Creator<T>
return creator.createFromParcel(parcel)
} finally {
parcel.recycle()
}
}
fun Data.Builder.putParcelable(key: String, parcelable: Parcelable): Data.Builder {
val parcel = Parcel.obtain()
try {
parcelable.writeToParcel(parcel, 0)
putByteArray(key, parcel.marshall())
} finally {
parcel.recycle()
}
return this
}

Some files were not shown because too many files have changed in this diff Show More