Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.motionapps.sensorbox.core.error

import android.content.Context
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

object AppDiagnostics {
Comment thread
Foxpace marked this conversation as resolved.
private val lock = Any()
Comment thread
Foxpace marked this conversation as resolved.
private val pending = ArrayDeque<String>()

@Volatile
Comment thread
Foxpace marked this conversation as resolved.
private var applicationContext: Context? = null

@Volatile
private var uncaughtHandlerInstalled = false

fun install(context: Context) {
synchronized(lock) {
applicationContext = context.applicationContext
installUncaughtExceptionHandler()
val queued = pending.toList()
pending.clear()
queued.forEach(::appendSafely)
}
}

private fun installUncaughtExceptionHandler() {
Comment thread
Foxpace marked this conversation as resolved.
if (uncaughtHandlerInstalled) return
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
AppError.from(AppError.Kind.UNKNOWN, "Uncaught exception on ${thread.name}", error)
Comment thread
Foxpace marked this conversation as resolved.
previous?.uncaughtException(thread, error)
}
uncaughtHandlerInstalled = true
}

fun record(error: AppError) {
val entry = error.toDiagnosticEntry()
synchronized(lock) {
if (applicationContext == null) {
if (pending.size == MAX_PENDING_ENTRIES) pending.removeFirst()
pending.addLast(entry)
} else {
appendSafely(entry)
}
}
}

fun readText(): Result<String> = appResult(AppError.Kind.STORAGE, "Read diagnostics") {
synchronized(lock) {
val file = diagnosticsFile()
if (file.exists()) file.readText() else NO_DIAGNOSTICS
}
}

fun exportFile(): Result<File> = appResult(AppError.Kind.STORAGE, "Export diagnostics") {
synchronized(lock) {
diagnosticsFile().also { file ->
file.parentFile?.mkdirs()
if (!file.exists()) file.writeText(NO_DIAGNOSTICS)
}
}
}

fun clear(): Result<Unit> = appResult(AppError.Kind.STORAGE, "Clear diagnostics") {
synchronized(lock) {
val file = diagnosticsFile()
check(!file.exists() || file.delete()) { "Unable to delete diagnostics" }
}
}

private fun appendSafely(entry: String) {
try {
val file = diagnosticsFile()
file.parentFile?.mkdirs()
if (file.length() + entry.length > MAX_FILE_BYTES) rotate(file)
file.appendText(entry)
} catch (_: Throwable) {
// Diagnostics must never become a second failure source.
Comment thread
Foxpace marked this conversation as resolved.
}
}

private fun rotate(file: File) {
val previous = File(file.parentFile, PREVIOUS_FILE_NAME)
if (previous.exists()) previous.delete()
if (file.exists()) file.renameTo(previous)
}

private fun diagnosticsFile(): File {
val context = checkNotNull(applicationContext) { "Diagnostics are not initialized" }
return File(File(context.filesDir, DIRECTORY_NAME), FILE_NAME)
}

private fun AppError.toDiagnosticEntry(): String = buildString {
val timestamp = SimpleDateFormat(TIMESTAMP_FORMAT, Locale.US).format(Date())
append(timestamp).append(" | ").append(kind).append(" | ").append(operation).appendLine()
append(stackTraceToString().take(MAX_ENTRY_CHARS)).appendLine()
appendLine(ENTRY_SEPARATOR)
}

private const val DIRECTORY_NAME = "diagnostics"
private const val FILE_NAME = "sensorbox-diagnostics.txt"
private const val PREVIOUS_FILE_NAME = "sensorbox-diagnostics-previous.txt"
private const val TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
private const val ENTRY_SEPARATOR = "---"
private const val NO_DIAGNOSTICS = "No diagnostics have been recorded.\n"
private const val MAX_PENDING_ENTRIES = 20
private const val MAX_ENTRY_CHARS = 32_000
private const val MAX_FILE_BYTES = 1_000_000L
}
73 changes: 73 additions & 0 deletions core/src/main/java/com/motionapps/sensorbox/core/error/AppError.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.motionapps.sensorbox.core.error

import kotlinx.coroutines.CancellationException

class AppError(val kind: Kind, val operation: String, cause: Throwable? = null) :
Exception(message(operation, cause), cause) {
init {
AppDiagnostics.record(this)
}

enum class Kind {
CONNECTIVITY,
EXTERNAL_ACTION,
MEASUREMENT,
PERMISSION,
PREFERENCES,
STORAGE,
UNKNOWN,
}

companion object {
fun from(kind: Kind, operation: String, cause: Throwable): AppError =
cause as? AppError ?: AppError(kind, operation, cause)

private fun message(operation: String, cause: Throwable?): String =
cause?.message?.takeIf(String::isNotBlank)?.let { "$operation: $it" } ?: "$operation failed"
}
}

@Suppress("TooGenericExceptionCaught")
inline fun <T> appResult(kind: AppError.Kind, operation: String, block: () -> T): Result<T> = try {
Result.success(block())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
Result.failure(AppError.from(kind, operation, error))
}

@Suppress("TooGenericExceptionCaught")
suspend inline fun <T> suspendAppResult(
kind: AppError.Kind,
operation: String,
crossinline block: suspend () -> T,
): Result<T> = try {
Result.success(block())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
Result.failure(AppError.from(kind, operation, error))
}

fun <T> Result<T>.withAppError(kind: AppError.Kind, operation: String): Result<T> = fold(
onSuccess = Result.Companion::success,
onFailure = { Result.failure(AppError.from(kind, operation, it)) },
)

inline fun <T, R> Result<T>.flatMap(transform: (T) -> Result<R>): Result<R> = fold(
onSuccess = transform,
onFailure = Result.Companion::failure,
)

suspend inline fun <T, R> Result<T>.suspendFlatMap(crossinline transform: suspend (T) -> Result<R>): Result<R> = fold(
onSuccess = { transform(it) },
onFailure = Result.Companion::failure,
)

fun Iterable<Result<*>>.combineAppResults(kind: AppError.Kind, operation: String): Result<Unit> {
val failures = mapNotNull(Result<*>::exceptionOrNull)
if (failures.isEmpty()) return Result.success(Unit)
val first = failures.first()
failures.drop(1).forEach(first::addSuppressed)
return Result.failure(AppError.from(kind, operation, first))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.motionapps.sensorbox.core.preferences

data class AppPreferences(
Comment thread
Foxpace marked this conversation as resolved.
val hasCompletedIntro: Boolean = false,
val hasAcceptedPolicy: Boolean = false,
val gpsIntervalSeconds: Int = 10,
val gpsMinDistanceMeters: Int = 20,
val sensorSamplingPeriod: Int = 0,
val restrictMeasurementOnLowBattery: Boolean = true,
val useWakeLock: Boolean = false,
val keepPhoneDisplayOn: Boolean = false,
val keepWearDisplayOn: Boolean = false,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.motionapps.sensorbox.core.preferences

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile

object AppPreferencesDataStoreFactory {
fun create(context: Context): DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile(FILE_NAME) },
)

private const val FILE_NAME = "sensorbox"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.motionapps.sensorbox.core.preferences

sealed interface AppPreferencesIntent {
data object CompleteIntro : AppPreferencesIntent

data object AcceptPolicy : AppPreferencesIntent

data class SetGpsInterval(val seconds: Int) : AppPreferencesIntent

data class SetGpsMinDistance(val meters: Int) : AppPreferencesIntent

data class SetSensorSamplingPeriod(val period: Int) : AppPreferencesIntent

data class SetLowBatteryRestriction(val enabled: Boolean) : AppPreferencesIntent

data class SetWakeLock(val enabled: Boolean) : AppPreferencesIntent

data class SetKeepPhoneDisplayOn(val enabled: Boolean) : AppPreferencesIntent

data class SetKeepWearDisplayOn(val enabled: Boolean) : AppPreferencesIntent
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.motionapps.sensorbox.core.preferences

object AppPreferencesReducer {
fun reduce(current: AppPreferences, intent: AppPreferencesIntent): AppPreferences = when (intent) {
AppPreferencesIntent.AcceptPolicy -> current.copy(hasAcceptedPolicy = true)

AppPreferencesIntent.CompleteIntro -> current.copy(hasCompletedIntro = true)

is AppPreferencesIntent.SetGpsInterval -> current.copy(
gpsIntervalSeconds = intent.seconds.coerceAtLeast(1),
)

is AppPreferencesIntent.SetGpsMinDistance -> current.copy(
gpsMinDistanceMeters = intent.meters.coerceAtLeast(0),
)

is AppPreferencesIntent.SetKeepWearDisplayOn -> current.copy(
keepWearDisplayOn = intent.enabled,
)

is AppPreferencesIntent.SetKeepPhoneDisplayOn -> current.copy(
keepPhoneDisplayOn = intent.enabled,
)

is AppPreferencesIntent.SetLowBatteryRestriction -> current.copy(
restrictMeasurementOnLowBattery = intent.enabled,
)

is AppPreferencesIntent.SetSensorSamplingPeriod -> current.copy(
sensorSamplingPeriod = intent.period,
)

is AppPreferencesIntent.SetWakeLock -> current.copy(useWakeLock = intent.enabled)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.motionapps.sensorbox.core.preferences

import kotlinx.coroutines.flow.Flow

interface AppPreferencesRepository {
val preferences: Flow<Result<AppPreferences>>

suspend fun dispatch(intent: AppPreferencesIntent): Result<Unit>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.motionapps.sensorbox.core.preferences

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import com.motionapps.sensorbox.core.error.AppError
import com.motionapps.sensorbox.core.error.suspendAppResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map

class DataStoreAppPreferencesRepository(private val dataStore: DataStore<Preferences>) : AppPreferencesRepository {
override val preferences: Flow<Result<AppPreferences>> = dataStore.data
.map { values -> Result.success(values.toAppPreferences()) }
.catch { error ->
if (error is CancellationException) throw error
emit(Result.failure(AppError.from(AppError.Kind.PREFERENCES, "Read preferences", error)))
}

override suspend fun dispatch(intent: AppPreferencesIntent): Result<Unit> = suspendAppResult(
AppError.Kind.PREFERENCES,
"Update preferences",
) {
dataStore.edit { values ->
val updated = AppPreferencesReducer.reduce(values.toAppPreferences(), intent)
values.write(updated)
}
}

private fun Preferences.toAppPreferences(): AppPreferences = AppPreferences(
hasCompletedIntro = this[Keys.COMPLETED_INTRO] ?: false,
hasAcceptedPolicy = this[Keys.ACCEPTED_POLICY] ?: false,
gpsIntervalSeconds = this[Keys.GPS_INTERVAL] ?: 10,
gpsMinDistanceMeters = this[Keys.GPS_DISTANCE] ?: 20,
sensorSamplingPeriod = this[Keys.SAMPLING_PERIOD] ?: 0,
restrictMeasurementOnLowBattery = this[Keys.LOW_BATTERY] ?: true,
useWakeLock = this[Keys.WAKE_LOCK] ?: false,
keepPhoneDisplayOn = this[Keys.KEEP_PHONE_DISPLAY_ON] ?: false,
keepWearDisplayOn = this[Keys.KEEP_DISPLAY_ON] ?: false,
)

private fun androidx.datastore.preferences.core.MutablePreferences.write(value: AppPreferences) {
this[Keys.COMPLETED_INTRO] = value.hasCompletedIntro
this[Keys.ACCEPTED_POLICY] = value.hasAcceptedPolicy
this[Keys.GPS_INTERVAL] = value.gpsIntervalSeconds
this[Keys.GPS_DISTANCE] = value.gpsMinDistanceMeters
this[Keys.SAMPLING_PERIOD] = value.sensorSamplingPeriod
this[Keys.LOW_BATTERY] = value.restrictMeasurementOnLowBattery
this[Keys.WAKE_LOCK] = value.useWakeLock
this[Keys.KEEP_PHONE_DISPLAY_ON] = value.keepPhoneDisplayOn
this[Keys.KEEP_DISPLAY_ON] = value.keepWearDisplayOn
}

private object Keys {
val COMPLETED_INTRO = booleanPreferencesKey("completed_intro")
val ACCEPTED_POLICY = booleanPreferencesKey("accepted_policy")
val GPS_INTERVAL = intPreferencesKey("gps_interval_seconds")
val GPS_DISTANCE = intPreferencesKey("gps_min_distance_meters")
val SAMPLING_PERIOD = intPreferencesKey("sensor_sampling_period")
val LOW_BATTERY = booleanPreferencesKey("restrict_on_low_battery")
val WAKE_LOCK = booleanPreferencesKey("use_wake_lock")
val KEEP_PHONE_DISPLAY_ON = booleanPreferencesKey("keep_phone_display_on")
val KEEP_DISPLAY_ON = booleanPreferencesKey("keep_wear_display_on")
}
}
Loading