diff --git a/README.md b/README.md
index aff118d1..3b0614cb 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,15 @@ Supported models:
* [Google Play](https://play.google.com/store/apps/details?id=eu.darken.cap)
* [GitHub](https://github.com/d4rken/android-airpods-companion/releases/latest)
+## Use CAP as a library
+
+AirPods use Apple's Continuity Protocol to communicate, specifically the Proximity Pairing Message type. If you want to
+build your own app based on this, you can include it like this:
+
+```groovy
+// TODO
+```
+
## Get help
* [Github Issues](https://github.com/d4rken/android-airpods-companion/issues)
diff --git a/app/build.gradle b/app/build.gradle
index 71f5651d..79da4c20 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -105,6 +105,7 @@ android {
"-Xuse-experimental=kotlinx.coroutines.FlowPreview",
"-Xuse-experimental=kotlin.time.ExperimentalTime",
"-Xuse-experimental=kotlin.ExperimentalUnsignedTypes",
+ "-Xuse-experimental=kotlin.contracts.ExperimentalContracts",
"-Xopt-in=kotlin.RequiresOptIn"
]
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index dfe72160..9bf7ea9b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,9 +11,9 @@
+ android:exported="true"
+ android:label="@string/app_name">
@@ -43,6 +43,24 @@
+
+
+
+
+
+
+
+
+
+
+ tools:node="remove" />
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/App.kt b/app/src/main/java/eu/darken/cap/App.kt
index 2e38a57f..67b7981b 100644
--- a/app/src/main/java/eu/darken/cap/App.kt
+++ b/app/src/main/java/eu/darken/cap/App.kt
@@ -5,8 +5,8 @@ import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.getkeepsafe.relinker.ReLinker
import dagger.hilt.android.HiltAndroidApp
-import eu.darken.cap.bugreporting.BugReporter
import eu.darken.cap.common.coroutine.AppScope
+import eu.darken.cap.common.debug.bugreporting.BugReporter
import eu.darken.cap.common.debug.logging.*
import eu.darken.cap.monitor.core.worker.MonitorControl
import kotlinx.coroutines.CoroutineScope
diff --git a/app/src/main/java/eu/darken/cap/common/BuildConfigWrap.kt b/app/src/main/java/eu/darken/cap/common/BuildConfigWrap.kt
index 697afaf7..a52421fd 100644
--- a/app/src/main/java/eu/darken/cap/common/BuildConfigWrap.kt
+++ b/app/src/main/java/eu/darken/cap/common/BuildConfigWrap.kt
@@ -13,4 +13,6 @@ object BuildConfigWrap {
val VERSION_CODE: Long = BuildConfig.VERSION_CODE.toLong()
val VERSION_NAME: String = BuildConfig.VERSION_NAME
val GIT_SHA: String = BuildConfig.GITSHA
+
+ val VERSION_DESCRIPTION: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA]"
}
diff --git a/app/src/main/java/eu/darken/cap/common/compression/Zipper.kt b/app/src/main/java/eu/darken/cap/common/compression/Zipper.kt
new file mode 100644
index 00000000..795aa16d
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/compression/Zipper.kt
@@ -0,0 +1,40 @@
+package eu.darken.cap.common.compression
+
+import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+// https://stackoverflow.com/a/48598099/1251958
+class Zipper {
+
+ @Throws(Exception::class)
+ fun zip(files: Array, zipFile: String) {
+
+ var origin: BufferedInputStream?
+ val out = ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile)))
+
+ for (i in files.indices) {
+ log(TAG, VERBOSE) { "Compressing ${files[i]} into $zipFile" }
+ origin = BufferedInputStream(FileInputStream(files[i]), BUFFER)
+
+ val entry = ZipEntry(files[i].substring(files[i].lastIndexOf("/") + 1))
+ out.putNextEntry(entry)
+
+ origin.use { input -> input.copyTo(out) }
+ }
+
+ out.finish()
+ out.close()
+ }
+
+ companion object {
+ internal val TAG = logTag("Zipper")
+ const val BUFFER = 2048
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/bugreporting/BugReportSettings.kt b/app/src/main/java/eu/darken/cap/common/debug/bugreporting/BugReportSettings.kt
similarity index 91%
rename from app/src/main/java/eu/darken/cap/bugreporting/BugReportSettings.kt
rename to app/src/main/java/eu/darken/cap/common/debug/bugreporting/BugReportSettings.kt
index 357494ad..cb665c53 100644
--- a/app/src/main/java/eu/darken/cap/bugreporting/BugReportSettings.kt
+++ b/app/src/main/java/eu/darken/cap/common/debug/bugreporting/BugReportSettings.kt
@@ -1,4 +1,4 @@
-package eu.darken.cap.bugreporting
+package eu.darken.cap.common.debug.bugreporting
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
diff --git a/app/src/main/java/eu/darken/cap/bugreporting/BugReporter.kt b/app/src/main/java/eu/darken/cap/common/debug/bugreporting/BugReporter.kt
similarity index 97%
rename from app/src/main/java/eu/darken/cap/bugreporting/BugReporter.kt
rename to app/src/main/java/eu/darken/cap/common/debug/bugreporting/BugReporter.kt
index e3046110..ee476f8d 100644
--- a/app/src/main/java/eu/darken/cap/bugreporting/BugReporter.kt
+++ b/app/src/main/java/eu/darken/cap/common/debug/bugreporting/BugReporter.kt
@@ -1,4 +1,4 @@
-package eu.darken.cap.bugreporting
+package eu.darken.cap.common.debug.bugreporting
import android.content.Context
import com.bugsnag.android.Bugsnag
diff --git a/app/src/main/java/eu/darken/cap/bugreporting/Bugs.kt b/app/src/main/java/eu/darken/cap/common/debug/bugreporting/Bugs.kt
similarity index 92%
rename from app/src/main/java/eu/darken/cap/bugreporting/Bugs.kt
rename to app/src/main/java/eu/darken/cap/common/debug/bugreporting/Bugs.kt
index 51c43ba6..f0e589a6 100644
--- a/app/src/main/java/eu/darken/cap/bugreporting/Bugs.kt
+++ b/app/src/main/java/eu/darken/cap/common/debug/bugreporting/Bugs.kt
@@ -1,4 +1,4 @@
-package eu.darken.cap.bugreporting
+package eu.darken.cap.common.debug.bugreporting
import com.bugsnag.android.Bugsnag
import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
diff --git a/app/src/main/java/eu/darken/cap/common/debug/logging/FileLogger.kt b/app/src/main/java/eu/darken/cap/common/debug/logging/FileLogger.kt
new file mode 100644
index 00000000..c1fa858e
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/debug/logging/FileLogger.kt
@@ -0,0 +1,79 @@
+package eu.darken.cap.common.debug.logging
+
+import android.annotation.SuppressLint
+import android.util.Log
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.OutputStreamWriter
+import java.time.Instant
+
+
+@SuppressLint("LogNotTimber")
+class FileLogger(private val logFile: File) : Logging.Logger {
+ private var logWriter: OutputStreamWriter? = null
+
+ @SuppressLint("SetWorldReadable")
+ @Synchronized
+ fun start() {
+ if (logWriter != null) return
+
+ logFile.parentFile!!.mkdirs()
+ if (logFile.createNewFile()) {
+ Log.i(TAG, "File logger writing to " + logFile.path)
+ }
+ if (logFile.setReadable(true, false)) {
+ Log.i(TAG, "Debug run log read permission set")
+ }
+
+ try {
+ logWriter = OutputStreamWriter(FileOutputStream(logFile, true))
+ logWriter!!.write("=== BEGIN ===\n")
+ logWriter!!.write("Logfile: $logFile\n")
+ logWriter!!.flush()
+ Log.i(TAG, "File logger started.")
+ } catch (e: IOException) {
+ e.printStackTrace()
+
+ logFile.delete()
+ if (logWriter != null) logWriter!!.close()
+ }
+
+ }
+
+ @Synchronized
+ fun stop() {
+ logWriter?.let {
+ logWriter = null
+ try {
+ it.write("=== END ===\n")
+ it.close()
+ } catch (ignore: IOException) {
+ }
+ Log.i(TAG, "File logger stopped.")
+ }
+ }
+
+ override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map?) {
+ logWriter?.let {
+ try {
+ it.write("${Instant.ofEpochMilli(System.currentTimeMillis())} ${priority.shortLabel}/$tag: $message\n")
+ it.flush()
+ } catch (e: IOException) {
+ Log.e(TAG, "Failed to write log line.", e)
+ try {
+ it.close()
+ } catch (ignore: Exception) {
+ }
+ logWriter = null
+ }
+ }
+ }
+
+ override fun toString(): String = "FileLogger(file=$logFile)"
+
+ companion object {
+ private val TAG = logTag("Debug", "FileLogger")
+ }
+}
+
diff --git a/app/src/main/java/eu/darken/cap/common/debug/recording/core/Recorder.kt b/app/src/main/java/eu/darken/cap/common/debug/recording/core/Recorder.kt
new file mode 100644
index 00000000..bd33345e
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/debug/recording/core/Recorder.kt
@@ -0,0 +1,48 @@
+package eu.darken.cap.common.debug.recording.core
+
+import eu.darken.cap.common.debug.logging.FileLogger
+import eu.darken.cap.common.debug.logging.Logging
+import eu.darken.cap.common.debug.logging.Logging.Priority.INFO
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.io.File
+import javax.inject.Inject
+
+class Recorder @Inject constructor() {
+ private val mutex = Mutex()
+ private var fileLogger: FileLogger? = null
+
+ val isRecording: Boolean
+ get() = path != null
+
+ var path: File? = null
+ private set
+
+ suspend fun start(path: File) = mutex.withLock {
+ if (fileLogger != null) return@withLock
+ this.path = path
+ fileLogger = FileLogger(path)
+ fileLogger?.let {
+ it.start()
+ Logging.install(it)
+ log(TAG, INFO) { "Now logging to file!" }
+ }
+ }
+
+ suspend fun stop() = mutex.withLock {
+ fileLogger?.let {
+ log(TAG, INFO) { "Stopping file-logger-tree: $it" }
+ Logging.remove(it)
+ it.stop()
+ fileLogger = null
+ this.path = null
+ }
+ }
+
+ companion object {
+ internal val TAG = logTag("Debug", "Log", "Recorder")
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/debug/recording/core/RecorderModule.kt b/app/src/main/java/eu/darken/cap/common/debug/recording/core/RecorderModule.kt
new file mode 100644
index 00000000..de99e666
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/debug/recording/core/RecorderModule.kt
@@ -0,0 +1,123 @@
+package eu.darken.cap.common.debug.recording.core
+
+import android.content.Context
+import android.content.Intent
+import android.os.Environment
+import dagger.hilt.android.qualifiers.ApplicationContext
+import eu.darken.cap.common.BuildConfigWrap
+import eu.darken.cap.common.coroutine.AppScope
+import eu.darken.cap.common.coroutine.DispatcherProvider
+import eu.darken.cap.common.debug.logging.Logging.Priority.ERROR
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+import eu.darken.cap.common.debug.recording.ui.RecorderActivity
+import eu.darken.cap.common.flow.DynamicStateFlow
+import eu.darken.cap.common.startServiceCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.plus
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class RecorderModule @Inject constructor(
+ @ApplicationContext private val context: Context,
+ @AppScope private val appScope: CoroutineScope,
+ private val dispatcherProvider: DispatcherProvider,
+) {
+
+ private val triggerFile = try {
+ File(context.getExternalFilesDir(null), FORCE_FILE)
+ } catch (e: Exception) {
+ File(
+ Environment.getExternalStorageDirectory(),
+ "/Android/data/${BuildConfigWrap.APPLICATION_ID}/files/$FORCE_FILE"
+ )
+ }
+
+ private val internalState = DynamicStateFlow(TAG, appScope + dispatcherProvider.IO) {
+ val triggerFileExists = triggerFile.exists()
+ State(shouldRecord = triggerFileExists)
+ }
+ val state: Flow = internalState.flow
+
+ init {
+ internalState.flow
+ .onEach {
+ log(TAG) { "New Recorder state: $internalState" }
+
+ internalState.updateBlocking {
+ if (!isRecording && shouldRecord) {
+ val newRecorder = Recorder()
+ newRecorder.start(createRecordingFilePath())
+ triggerFile.createNewFile()
+
+ context.startServiceCompat(Intent(context, RecorderService::class.java))
+
+ copy(
+ recorder = newRecorder
+ )
+ } else if (!shouldRecord && isRecording) {
+ val currentLog = recorder!!.path!!
+ recorder.stop()
+
+ if (triggerFile.exists() && !triggerFile.delete()) {
+ log(TAG, ERROR) { "Failed to delete trigger file" }
+ }
+
+ val intent = RecorderActivity.getLaunchIntent(context, currentLog.path).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ copy(
+ recorder = null,
+ lastLogPath = currentLog
+ )
+ } else {
+ this
+ }
+ }
+ }
+ .launchIn(appScope)
+ }
+
+ private fun createRecordingFilePath() = File(
+ File(context.cacheDir, "debug/logs"),
+ "bb_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 = "bb_force_debug_run"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/debug/recording/core/RecorderService.kt b/app/src/main/java/eu/darken/cap/common/debug/recording/core/RecorderService.kt
new file mode 100644
index 00000000..35b69f19
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/debug/recording/core/RecorderService.kt
@@ -0,0 +1,115 @@
+package eu.darken.cap.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.cap.R
+import eu.darken.cap.common.BuildConfigWrap
+import eu.darken.cap.common.coroutine.DispatcherProvider
+import eu.darken.cap.common.debug.logging.log
+import eu.darken.cap.common.debug.logging.logTag
+import eu.darken.cap.common.notifications.PendingIntentCompat
+import eu.darken.cap.common.smart.SmartService
+import eu.darken.cap.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 : SmartService() {
+ 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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/debug/recording/ui/RecorderActivity.kt b/app/src/main/java/eu/darken/cap/common/debug/recording/ui/RecorderActivity.kt
new file mode 100644
index 00000000..622ca2b6
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/debug/recording/ui/RecorderActivity.kt
@@ -0,0 +1,58 @@
+package eu.darken.cap.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 dagger.hilt.android.AndroidEntryPoint
+import eu.darken.cap.common.debug.logging.logTag
+import eu.darken.cap.common.error.asErrorDialogBuilder
+import eu.darken.cap.common.smart.SmartActivity
+import eu.darken.cap.databinding.CoreDebugRecordingActivityBinding
+
+@AndroidEntryPoint
+class RecorderActivity : SmartActivity() {
+
+ private lateinit var ui: CoreDebugRecordingActivityBinding
+ private val vm: RecorderActivityVM by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ ui = CoreDebugRecordingActivityBinding.inflate(layoutInflater)
+ setContentView(ui.root)
+
+ vm.state.observe2 { state ->
+ ui.loadingIndicator.isInvisible = !state.loading
+ ui.share.isInvisible = state.loading
+
+ ui.recordingPath.text = state.normalPath
+
+ if (state.normalSize != -1L) {
+ ui.recordingSize.text = Formatter.formatShortFileSize(this, state.normalSize)
+ }
+ if (state.compressedSize != -1L) {
+ ui.recordingSizeCompressed.text = Formatter.formatShortFileSize(this, state.compressedSize)
+ }
+ }
+
+ vm.errorEvents.observe2 {
+ it.asErrorDialogBuilder(this).show()
+ }
+
+ ui.share.setOnClickListener { vm.share() }
+ vm.shareEvent.observe2 { startActivity(it) }
+ }
+
+ companion object {
+ internal val TAG = logTag("Debug", "Log", "RecorderActivity")
+ const val RECORD_PATH = "logPath"
+
+ fun getLaunchIntent(context: Context, path: String): Intent {
+ val intent = Intent(context, RecorderActivity::class.java)
+ intent.putExtra(RECORD_PATH, path)
+ return intent
+ }
+ }
+}
diff --git a/app/src/main/java/eu/darken/cap/common/debug/recording/ui/RecorderActivityVM.kt b/app/src/main/java/eu/darken/cap/common/debug/recording/ui/RecorderActivityVM.kt
new file mode 100644
index 00000000..2c9b9cb2
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/debug/recording/ui/RecorderActivityVM.kt
@@ -0,0 +1,113 @@
+package eu.darken.cap.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.cap.R
+import eu.darken.cap.common.BuildConfigWrap
+import eu.darken.cap.common.compression.Zipper
+import eu.darken.cap.common.coroutine.DispatcherProvider
+import eu.darken.cap.common.debug.logging.logTag
+import eu.darken.cap.common.flow.DynamicStateFlow
+import eu.darken.cap.common.flow.onError
+import eu.darken.cap.common.flow.replayingShare
+import eu.darken.cap.common.livedata.SingleLiveEvent
+import eu.darken.cap.common.smart.Smart2VM
+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,
+) : Smart2VM(dispatcherProvider) {
+
+ private val recordedPath = handle.get(RecorderActivity.RECORD_PATH)!!
+ private val pathCache = MutableStateFlow(recordedPath)
+ private val resultCacheObs = pathCache
+ .map { path -> Pair(path, File(path).length()) }
+ .replayingShare(vmScope)
+
+ private val resultCacheCompressedObs = resultCacheObs
+ .map { uncompressed ->
+ val zipped = "${uncompressed.first}.zip"
+ Zipper().zip(arrayOf(uncompressed.first), zipped)
+ Pair(zipped, File(zipped).length())
+ }
+ .replayingShare(vmScope + dispatcherProvider.IO)
+
+ private val stater = DynamicStateFlow(TAG, vmScope) { State() }
+ val state = stater.asLiveData2()
+
+ val shareEvent = SingleLiveEvent()
+
+ init {
+ resultCacheObs
+ .onEach { (path, size) ->
+ stater.updateBlocking { copy(normalPath = path, normalSize = size) }
+ }
+ .launchInViewModel()
+
+ resultCacheCompressedObs
+ .onEach { (path, size) ->
+ stater.updateBlocking {
+ copy(
+ compressedPath = path,
+ compressedSize = size,
+ loading = false
+ )
+ }
+ }
+ .onError { errorEvents.postValue(it) }
+ .launchInViewModel()
+
+ }
+
+ fun share() = launch {
+ val (path, size) = resultCacheCompressedObs.first()
+
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ val uri = FileProvider.getUriForFile(
+ context,
+ BuildConfigWrap.APPLICATION_ID + ".provider",
+ File(path)
+ )
+
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ type = "application/zip"
+
+ addCategory(Intent.CATEGORY_DEFAULT)
+ putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - ${BuildConfigWrap.VERSION_DESCRIPTION})")
+ putExtra(Intent.EXTRA_TEXT, "Your text here.")
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+
+ val chooserIntent = Intent.createChooser(intent, context.getString(R.string.debug_log_file_label))
+ shareEvent.postValue(chooserIntent)
+ }
+
+ data class State(
+ val normalPath: String? = null,
+ val normalSize: Long = -1L,
+ val compressedPath: String? = null,
+ val compressedSize: Long = -1L,
+ val loading: Boolean = true
+ )
+
+ companion object {
+ private val TAG = logTag("Debug", "Recorder", "VM")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/notifications/PendingIntentCompat.kt b/app/src/main/java/eu/darken/cap/common/notifications/PendingIntentCompat.kt
new file mode 100644
index 00000000..4caf63d6
--- /dev/null
+++ b/app/src/main/java/eu/darken/cap/common/notifications/PendingIntentCompat.kt
@@ -0,0 +1,12 @@
+package eu.darken.cap.common.notifications
+
+import android.app.PendingIntent
+import eu.darken.cap.common.hasApiLevel
+
+object PendingIntentCompat {
+ val FLAG_IMMUTABLE: Int = if (hasApiLevel(31)) {
+ PendingIntent.FLAG_IMMUTABLE
+ } else {
+ 0
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/common/smart/SmartActivity.kt b/app/src/main/java/eu/darken/cap/common/smart/SmartActivity.kt
index 2f564a76..1daf1c51 100644
--- a/app/src/main/java/eu/darken/cap/common/smart/SmartActivity.kt
+++ b/app/src/main/java/eu/darken/cap/common/smart/SmartActivity.kt
@@ -3,6 +3,7 @@ package eu.darken.cap.common.smart
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.LiveData
import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.cap.common.debug.logging.log
import eu.darken.cap.common.debug.logging.logTag
@@ -36,4 +37,8 @@ abstract class SmartActivity : AppCompatActivity() {
super.onActivityResult(requestCode, resultCode, data)
}
+ fun LiveData.observe2(callback: (T) -> Unit) {
+ observe(this@SmartActivity) { callback.invoke(it) }
+ }
+
}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/main/ui/MainFragment.kt b/app/src/main/java/eu/darken/cap/main/ui/MainFragment.kt
index fbdc461d..1afe297f 100644
--- a/app/src/main/java/eu/darken/cap/main/ui/MainFragment.kt
+++ b/app/src/main/java/eu/darken/cap/main/ui/MainFragment.kt
@@ -40,8 +40,22 @@ class MainFragment : Smart2Fragment(R.layout.main_fragment) {
ui.apply {
list.setupDefaults(adapter)
- toolbar.subtitle =
- "v${BuildConfigWrap.VERSION_NAME} (${BuildConfigWrap.VERSION_CODE}) [${BuildConfigWrap.GIT_SHA}]"
+ }
+ ui.toolbar.apply {
+ subtitle = BuildConfigWrap.VERSION_DESCRIPTION
+ setOnMenuItemClickListener {
+ when (it.itemId) {
+ R.id.menu_item_debuglog -> {
+ vm.toggleDebugLog()
+ true
+ }
+ R.id.menu_item_settings -> {
+ vm.goToSettings()
+ true
+ }
+ else -> false
+ }
+ }
}
vm.listItems.observe2(ui) {
diff --git a/app/src/main/java/eu/darken/cap/main/ui/MainFragmentVM.kt b/app/src/main/java/eu/darken/cap/main/ui/MainFragmentVM.kt
index f3d7fcd6..eb654fc9 100644
--- a/app/src/main/java/eu/darken/cap/main/ui/MainFragmentVM.kt
+++ b/app/src/main/java/eu/darken/cap/main/ui/MainFragmentVM.kt
@@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.cap.common.coroutine.DispatcherProvider
+import eu.darken.cap.common.debug.recording.core.RecorderModule
import eu.darken.cap.common.hasApiLevel
import eu.darken.cap.common.livedata.SingleLiveEvent
import eu.darken.cap.common.permissions.Permission
@@ -22,6 +23,7 @@ class MainFragmentVM @Inject constructor(
handle: SavedStateHandle,
@ApplicationContext private val context: Context,
dispatcherProvider: DispatcherProvider,
+ private val recorderModule: RecorderModule,
) : Smart2VM(dispatcherProvider = dispatcherProvider) {
@@ -65,4 +67,12 @@ class MainFragmentVM @Inject constructor(
if (granted) permissionCheckTrigger.value = UUID.randomUUID()
}
+ fun toggleDebugLog() = launch {
+ recorderModule.startRecorder()
+ }
+
+ fun goToSettings() = launch {
+
+ }
+
}
\ No newline at end of file
diff --git a/app/src/main/java/eu/darken/cap/monitor/ui/MonitorNotifications.kt b/app/src/main/java/eu/darken/cap/monitor/ui/MonitorNotifications.kt
index 957b6365..a8bd4b6a 100644
--- a/app/src/main/java/eu/darken/cap/monitor/ui/MonitorNotifications.kt
+++ b/app/src/main/java/eu/darken/cap/monitor/ui/MonitorNotifications.kt
@@ -8,7 +8,6 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
-import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.work.ForegroundInfo
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -17,6 +16,7 @@ import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.cap.common.debug.logging.log
import eu.darken.cap.common.debug.logging.logTag
import eu.darken.cap.common.hasApiLevel
+import eu.darken.cap.common.notifications.PendingIntentCompat
import eu.darken.cap.main.ui.MainActivity
import eu.darken.cap.pods.core.PodDevice
import javax.inject.Inject
@@ -30,21 +30,19 @@ class MonitorNotifications @Inject constructor(
private val builder: NotificationCompat.Builder
init {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val channel = NotificationChannel(
- NOTIFICATION_CHANNEL_ID,
- context.getString(R.string.notification_channel_device_status_label),
- NotificationManager.IMPORTANCE_LOW
- )
- notificationManager.createNotificationChannel(channel)
- }
+ NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ context.getString(R.string.notification_channel_device_status_label),
+ NotificationManager.IMPORTANCE_LOW
+ ).run { notificationManager.createNotificationChannel(this) }
val openIntent = Intent(context, MainActivity::class.java)
- val openPi = if (hasApiLevel(31)) {
- PendingIntent.getActivity(context, 0, openIntent, PendingIntent.FLAG_IMMUTABLE)
- } else {
- PendingIntent.getActivity(context, 0, openIntent, 0)
- }
+ val openPi = PendingIntent.getActivity(
+ context,
+ 0,
+ openIntent,
+ PendingIntentCompat.FLAG_IMMUTABLE
+ )
builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setChannelId(NOTIFICATION_CHANNEL_ID)
diff --git a/app/src/main/java/eu/darken/cap/pods/core/airpods/AirPodsFactory.kt b/app/src/main/java/eu/darken/cap/pods/core/airpods/AirPodsFactory.kt
index 1120f962..77e626cf 100644
--- a/app/src/main/java/eu/darken/cap/pods/core/airpods/AirPodsFactory.kt
+++ b/app/src/main/java/eu/darken/cap/pods/core/airpods/AirPodsFactory.kt
@@ -2,7 +2,7 @@ package eu.darken.cap.pods.core.airpods
import android.bluetooth.le.ScanResult
import dagger.Reusable
-import eu.darken.cap.bugreporting.Bugs
+import eu.darken.cap.common.debug.bugreporting.Bugs
import eu.darken.cap.common.debug.logging.Logging.Priority.INFO
import eu.darken.cap.common.debug.logging.Logging.Priority.WARN
import eu.darken.cap.common.debug.logging.asLog
diff --git a/app/src/main/res/drawable/ic_baseline_bug_report_24.xml b/app/src/main/res/drawable/ic_baseline_bug_report_24.xml
new file mode 100644
index 00000000..bc724e02
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_bug_report_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/debug_recording_activity.xml b/app/src/main/res/layout/debug_recording_activity.xml
new file mode 100644
index 00000000..1a11811c
--- /dev/null
+++ b/app/src/main/res/layout/debug_recording_activity.xml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/main_fragment.xml b/app/src/main/res/layout/main_fragment.xml
index ad74c387..792acd84 100644
--- a/app/src/main/res/layout/main_fragment.xml
+++ b/app/src/main/res/layout/main_fragment.xml
@@ -13,6 +13,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
+ app:menu="@menu/main"
app:title="@string/app_name" />
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d793dd89..04b05e46 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -7,4 +7,12 @@
Device status
BLUETOOTH SCAN
Required to be able to discover and pair nearby Bluetooth devices.
+ Share
+ Done
+ Size
+ Compressed size
+ Debug notifications
+ Recorded log file
+ Record debug log
+ Settings
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 53bffe0b..361a9055 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -29,4 +29,14 @@
- @color/md_theme_light_primaryInverse
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml
new file mode 100644
index 00000000..e65ff667
--- /dev/null
+++ b/app/src/main/res/xml/file_provider_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file