mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 19:26:12 -04:00
Go multi-module: app-common, app-phone, app-wear
This commit is contained in:
@@ -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 |
@@ -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,
|
||||
)
|
||||
|
||||
}
|
||||
-59
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
-19
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-91
@@ -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
Reference in New Issue
Block a user