mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Adds debug log recording.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:name="eu.darken.cap.App"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:name="eu.darken.cap.App"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
@@ -21,8 +21,8 @@
|
||||
|
||||
<activity
|
||||
android:name="eu.darken.cap.main.ui.MainActivity"
|
||||
android:label="@string/app_name"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -43,6 +43,24 @@
|
||||
</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"
|
||||
@@ -51,7 +69,7 @@
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove"></provider>
|
||||
tools:node="remove" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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
|
||||
|
||||
@@ -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]"
|
||||
}
|
||||
|
||||
@@ -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<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
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
@@ -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<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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<State> = 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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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})")
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 <T> LiveData<T>.observe2(callback: (T) -> Unit) {
|
||||
observe(this@SmartActivity) { callback.invoke(it) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView style="@style/MyCardView"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minWidth="320dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/label_path"
|
||||
style="@style/TextAppearance.MaterialComponents.Caption"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Recorded file"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:ignore="HardcodedText" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/recording_path"
|
||||
style="@style/TextAppearance.MaterialComponents.Body1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/label_path"
|
||||
tools:text="/storage/emulated/0/Android/data/eu.darken.cap/cache/log_files/some_log_file_123124.log" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/label_size"
|
||||
style="@style/TextAppearance.MaterialComponents.Caption"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/debug_debuglog_size_label"
|
||||
app:layout_constraintEnd_toStartOf="@+id/label_compressed_size"
|
||||
app:layout_constraintHorizontal_chainStyle="spread_inside"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/recording_path" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/recording_size"
|
||||
style="@style/TextAppearance.AppCompat.Body1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="32dp"
|
||||
app:layout_constraintEnd_toStartOf="@+id/recording_size_compressed"
|
||||
app:layout_constraintHorizontal_chainStyle="spread_inside"
|
||||
app:layout_constraintStart_toStartOf="@+id/label_size"
|
||||
app:layout_constraintTop_toBottomOf="@+id/label_size"
|
||||
tools:text="72 MB" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/label_compressed_size"
|
||||
style="@style/TextAppearance.MaterialComponents.Caption"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/debug_debuglog_size_compressed_label"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="1.0"
|
||||
app:layout_constraintStart_toEndOf="@+id/label_size"
|
||||
app:layout_constraintTop_toBottomOf="@+id/recording_path" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/recording_size_compressed"
|
||||
style="@style/TextAppearance.AppCompat.Body1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintEnd_toEndOf="@+id/label_compressed_size"
|
||||
app:layout_constraintStart_toEndOf="@+id/recording_size"
|
||||
app:layout_constraintTop_toBottomOf="@+id/label_compressed_size"
|
||||
tools:text="745 KB" />
|
||||
|
||||
<androidx.core.widget.ContentLoadingProgressBar
|
||||
android:id="@+id/loading_indicator"
|
||||
style="@style/Widget.Material3.CircularProgressIndicator.Medium"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/share"
|
||||
app:layout_constraintEnd_toEndOf="@+id/share"
|
||||
app:layout_constraintStart_toStartOf="@+id/share"
|
||||
app:layout_constraintTop_toTopOf="@+id/share" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/share"
|
||||
style="@style/Widget.Material3.Button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/general_share_action"
|
||||
android:visibility="invisible"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/recording_size_compressed" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -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" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/menu_item_debuglog"
|
||||
android:title="@string/debug_debuglog_record_action" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_item_settings"
|
||||
android:title="@string/general_settings_label" />
|
||||
</menu>
|
||||
@@ -7,4 +7,12 @@
|
||||
<string name="notification_channel_device_status_label">Device status</string>
|
||||
<string name="permission_bluetooth_scan_label">BLUETOOTH SCAN</string>
|
||||
<string name="permission_bluetooth_scan_description">Required to be able to discover and pair nearby Bluetooth devices.</string>
|
||||
<string name="general_share_action">Share</string>
|
||||
<string name="general_done_action">Done</string>
|
||||
<string name="debug_debuglog_size_label">Size</string>
|
||||
<string name="debug_debuglog_size_compressed_label">Compressed size</string>
|
||||
<string name="debug_notification_channel_label">Debug notifications</string>
|
||||
<string name="debug_debuglog_file_label">Recorded log file</string>
|
||||
<string name="debug_debuglog_record_action">Record debug log</string>
|
||||
<string name="general_settings_label">Settings</string>
|
||||
</resources>
|
||||
@@ -29,4 +29,14 @@
|
||||
<item name="colorPrimaryInverse">@color/md_theme_light_primaryInverse</item>
|
||||
</style>
|
||||
|
||||
<style name="AppThemeFloating" parent="AppTheme">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:windowIsFloating">true</item>
|
||||
<item name="android:backgroundDimEnabled">true</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="debug_logs"
|
||||
path="debug/logs" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user