From 817c65ee3b016392bbd523a5532d7b71cc8f895f Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 29 Dec 2021 22:18:53 +0100 Subject: [PATCH] Adds debug log recording. --- README.md | 9 ++ app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 26 +++- app/src/main/java/eu/darken/cap/App.kt | 2 +- .../eu/darken/cap/common/BuildConfigWrap.kt | 2 + .../darken/cap/common/compression/Zipper.kt | 40 ++++++ .../debug}/bugreporting/BugReportSettings.kt | 2 +- .../debug}/bugreporting/BugReporter.kt | 2 +- .../{ => common/debug}/bugreporting/Bugs.kt | 2 +- .../cap/common/debug/logging/FileLogger.kt | 79 +++++++++++ .../common/debug/recording/core/Recorder.kt | 48 +++++++ .../debug/recording/core/RecorderModule.kt | 123 ++++++++++++++++++ .../debug/recording/core/RecorderService.kt | 115 ++++++++++++++++ .../debug/recording/ui/RecorderActivity.kt | 58 +++++++++ .../debug/recording/ui/RecorderActivityVM.kt | 113 ++++++++++++++++ .../notifications/PendingIntentCompat.kt | 12 ++ .../darken/cap/common/smart/SmartActivity.kt | 5 + .../eu/darken/cap/main/ui/MainFragment.kt | 18 ++- .../eu/darken/cap/main/ui/MainFragmentVM.kt | 10 ++ .../cap/monitor/ui/MonitorNotifications.kt | 26 ++-- .../cap/pods/core/airpods/AirPodsFactory.kt | 2 +- .../drawable/ic_baseline_bug_report_24.xml | 10 ++ .../res/layout/debug_recording_activity.xml | 119 +++++++++++++++++ app/src/main/res/layout/main_fragment.xml | 1 + app/src/main/res/menu/main.xml | 10 ++ app/src/main/res/values/strings.xml | 8 ++ app/src/main/res/values/themes.xml | 10 ++ app/src/main/res/xml/file_provider_paths.xml | 6 + 28 files changed, 834 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/eu/darken/cap/common/compression/Zipper.kt rename app/src/main/java/eu/darken/cap/{ => common/debug}/bugreporting/BugReportSettings.kt (91%) rename app/src/main/java/eu/darken/cap/{ => common/debug}/bugreporting/BugReporter.kt (97%) rename app/src/main/java/eu/darken/cap/{ => common/debug}/bugreporting/Bugs.kt (92%) create mode 100644 app/src/main/java/eu/darken/cap/common/debug/logging/FileLogger.kt create mode 100644 app/src/main/java/eu/darken/cap/common/debug/recording/core/Recorder.kt create mode 100644 app/src/main/java/eu/darken/cap/common/debug/recording/core/RecorderModule.kt create mode 100644 app/src/main/java/eu/darken/cap/common/debug/recording/core/RecorderService.kt create mode 100644 app/src/main/java/eu/darken/cap/common/debug/recording/ui/RecorderActivity.kt create mode 100644 app/src/main/java/eu/darken/cap/common/debug/recording/ui/RecorderActivityVM.kt create mode 100644 app/src/main/java/eu/darken/cap/common/notifications/PendingIntentCompat.kt create mode 100644 app/src/main/res/drawable/ic_baseline_bug_report_24.xml create mode 100644 app/src/main/res/layout/debug_recording_activity.xml create mode 100644 app/src/main/res/menu/main.xml create mode 100644 app/src/main/res/xml/file_provider_paths.xml 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