Skip to content
Open
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
137 changes: 107 additions & 30 deletions app/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.motionapps.sensorbox.activities

import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Intent
import android.net.Uri
import android.os.Bundle
Expand All @@ -16,60 +17,116 @@ import androidx.compose.runtime.getValue
import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.motionapps.sensorbox.R
import com.motionapps.sensorbox.core.error.AppDiagnostics
import com.motionapps.sensorbox.core.error.AppError
import com.motionapps.sensorbox.core.error.AppErrorCode
import com.motionapps.sensorbox.core.error.AppResult
import com.motionapps.sensorbox.core.error.DiagnosticsStore
import com.motionapps.sensorbox.core.error.appResult
import com.motionapps.sensorbox.core.error.flatMap
import com.motionapps.sensorbox.presentation.main.MainEffect
import com.motionapps.sensorbox.presentation.main.MainViewModel
import com.motionapps.sensorbox.presentation.main.OnboardingEffect
import com.motionapps.sensorbox.presentation.main.OnboardingViewModel
import com.motionapps.sensorbox.presentation.main.RecordingEffect
import com.motionapps.sensorbox.presentation.main.RecordingViewModel
import com.motionapps.sensorbox.presentation.main.SensorBoxApp
import com.motionapps.sensorbox.presentation.main.SettingsEffect
import com.motionapps.sensorbox.presentation.main.SettingsViewModel
import com.motionapps.sensorbox.ui.theme.SensorBoxTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
@Inject
lateinit var diagnosticsStore: DiagnosticsStore

private val mainViewModel: MainViewModel by viewModels()
private val onboardingViewModel: OnboardingViewModel by viewModels()
private val recordingViewModel: RecordingViewModel by viewModels()
private val settingsViewModel: SettingsViewModel by viewModels()
private var storageRequestOwner = StorageRequestOwner.RECORDING
private val directoryPicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
viewModel.handleStorageResult(it.data)
when (storageRequestOwner) {
StorageRequestOwner.ONBOARDING -> onboardingViewModel.handleStorageResult(it.data)
StorageRequestOwner.RECORDING -> recordingViewModel.handleStorageResult(it.data)
}
}
private val permissionRequest = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
viewModel.handlePermissionResult()
recordingViewModel.handlePermissionResult()
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.action == Intent.ACTION_VIEW_PERMISSION_USAGE) viewModel.showPrivacyRationale()
if (intent.action == Intent.ACTION_VIEW_PERMISSION_USAGE) mainViewModel.showPrivacyRationale()
setContent {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) { viewModel.effects.collect(::handleEffect) }
val mainState by mainViewModel.state.collectAsStateWithLifecycle()
val onboardingState by onboardingViewModel.state.collectAsStateWithLifecycle()
val recordingState by recordingViewModel.state.collectAsStateWithLifecycle()
val settingsState by settingsViewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(onboardingViewModel) {
onboardingViewModel.effects.collect(::handleOnboardingEffect)
}
LaunchedEffect(recordingViewModel) {
recordingViewModel.effects.collect(::handleRecordingEffect)
}
LaunchedEffect(settingsViewModel) {
settingsViewModel.effects.collect(::handleSettingsEffect)
}
SensorBoxTheme {
SensorBoxApp(state = state, onIntent = viewModel::accept)
SensorBoxApp(
mainState = mainState,
onboardingState = onboardingState,
recordingState = recordingState,
settingsState = settingsState,
onNavigate = mainViewModel::navigate,
onOnboardingIntent = onboardingViewModel::accept,
onRecordingIntent = recordingViewModel::accept,
onSettingsIntent = settingsViewModel::accept,
)
}
}
}

private fun handleEffect(effect: MainEffect) {
private fun handleOnboardingEffect(effect: OnboardingEffect) {
when (effect) {
MainEffect.PickStorageDirectory -> appResult(AppError.Kind.EXTERNAL_ACTION, "Open storage picker") {
directoryPicker.launch(storageIntent())
}

MainEffect.OpenPrivacyPolicy -> openWebPage(getString(R.string.link_privacy_policy))

MainEffect.OpenTermsOfUse -> openWebPage(getString(R.string.link_terms))

MainEffect.RequestBatteryOptimizationExemption -> requestBatteryOptimizationExemption()
OnboardingEffect.PickStorageDirectory -> openStoragePicker(StorageRequestOwner.ONBOARDING)
OnboardingEffect.OpenPrivacyPolicy -> openWebPage(getString(R.string.link_privacy_policy))
OnboardingEffect.OpenTermsOfUse -> openWebPage(getString(R.string.link_terms))
OnboardingEffect.RequestBatteryOptimizationExemption -> requestBatteryOptimizationExemption()
is OnboardingEffect.Navigate -> mainViewModel.navigate(effect.route)
}
}

MainEffect.ShareDiagnosticsText -> shareDiagnosticsText()
private fun handleRecordingEffect(effect: RecordingEffect) {
when (effect) {
RecordingEffect.PickStorageDirectory -> openStoragePicker(StorageRequestOwner.RECORDING)

MainEffect.ShareDiagnosticsFile -> shareDiagnosticsFile()
is RecordingEffect.Navigate -> mainViewModel.navigate(effect.route)

is MainEffect.RequestPermissions -> appResult(AppError.Kind.PERMISSION, "Request app permissions") {
is RecordingEffect.RequestPermissions -> appResult(AppErrorCode.PERMISSION, "Request app permissions") {
permissionRequest.launch(effect.permissions.toTypedArray())
}
}
}

private fun handleSettingsEffect(effect: SettingsEffect) {
when (effect) {
SettingsEffect.RequestBatteryOptimizationExemption -> requestBatteryOptimizationExemption()
SettingsEffect.ShareDiagnosticsText -> shareDiagnosticsText()
SettingsEffect.ShareDiagnosticsFile -> shareDiagnosticsFile()
is SettingsEffect.CopyDiagnosticsText -> copyDiagnostics(effect.text)
SettingsEffect.DiagnosticsCleared -> showToast(R.string.diagnostics_cleared)
is SettingsEffect.DiagnosticsFailed -> showToast(R.string.diagnostics_action_failed)
is SettingsEffect.Navigate -> mainViewModel.navigate(effect.route)
}
}

private fun openStoragePicker(owner: StorageRequestOwner) {
storageRequestOwner = owner
appResult(AppErrorCode.EXTERNAL_ACTION, "Open storage picker") {
directoryPicker.launch(storageIntent())
}
}

private fun openWebPage(url: String) {
launchExternalIntent(Intent(Intent.ACTION_VIEW, Uri.parse(url)), "Open web page")
}
Expand All @@ -89,7 +146,7 @@ class MainActivity : ComponentActivity() {
}

private fun shareDiagnosticsText() {
AppDiagnostics.readText().fold(
diagnosticsStore.readText().fold(
onSuccess = { diagnostics ->
val intent = Intent(Intent.ACTION_SEND)
.setType("text/plain")
Expand All @@ -102,9 +159,9 @@ class MainActivity : ComponentActivity() {
}

private fun shareDiagnosticsFile() {
AppDiagnostics.exportFile().fold(
diagnosticsStore.exportFile().fold(
onSuccess = { file ->
appResult(AppError.Kind.EXTERNAL_ACTION, "Prepare diagnostics file") {
appResult(AppErrorCode.EXTERNAL_ACTION, "Prepare diagnostics file") {
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file)
val intent = Intent(Intent.ACTION_SEND)
.setType("text/plain")
Expand All @@ -119,23 +176,43 @@ class MainActivity : ComponentActivity() {
)
}

private fun launchShareIntent(intent: Intent): Result<Unit> =
private fun launchShareIntent(intent: Intent): AppResult<Unit> =
launchExternalIntent(Intent.createChooser(intent, getString(R.string.diagnostics_share)), "Share diagnostics")

private fun launchExternalIntent(intent: Intent, operation: String): Result<Unit> = appResult(
AppError.Kind.EXTERNAL_ACTION,
private fun launchExternalIntent(intent: Intent, operation: String): AppResult<Unit> = appResult(
AppErrorCode.EXTERNAL_ACTION,
operation,
) {
startActivity(intent)
}

private fun showDiagnosticsShareFailure() {
Toast.makeText(this, R.string.diagnostics_share_failed, Toast.LENGTH_LONG).show()
showToast(R.string.diagnostics_share_failed)
}

private fun copyDiagnostics(text: String) {
appResult(AppErrorCode.EXTERNAL_ACTION, "Copy diagnostics") {
getSystemService(ClipboardManager::class.java).setPrimaryClip(
ClipData.newPlainText(getString(R.string.diagnostics_title), text),
)
}.fold(
onSuccess = { showToast(R.string.diagnostics_copied) },
onFailure = { showToast(R.string.diagnostics_action_failed) },
)
}

private fun showToast(message: Int) {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}

private fun storageIntent() = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
}

private enum class StorageRequestOwner {
ONBOARDING,
RECORDING,
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package com.motionapps.sensorbox.presentation.main

import androidx.navigation3.runtime.NavKey
import com.motionapps.sensorbox.core.preferences.AppPreferences
import com.motionapps.sensorbox.domain.sensors.SensorDescriptor
import com.motionapps.sensorservices.session.MeasurementSessionState
import kotlinx.serialization.Serializable

Expand All @@ -19,160 +17,9 @@ enum class MainRoute : NavKey {
PRIVACY,
}

enum class MainMessage {
NONE,
PICK_AT_LEAST_ONE_SOURCE,
STORAGE_REQUIRED,
PERMISSION_REQUIRED,
MEASUREMENT_FAILED,
}

data class MainState(
val route: MainRoute = MainRoute.ONBOARDING,
val onboardingPage: Int = 0,
val sensors: List<SensorDescriptor> = emptyList(),
val wearSensors: List<SensorDescriptor> = emptyList(),
val detailsSensorType: Int? = null,
val selectedSensorIds: Set<Int> = emptySet(),
val includesGps: Boolean = false,
val selectedWearSensorIds: Set<Int> = emptySet(),
val wearIncludesGps: Boolean = false,
val customMeasurementName: String = "",
val measurementType: String = "ENDLESS",
val startDelaySeconds: Int = 0,
val durationSeconds: Int = 0,
val notes: String = "",
val alarmOffsets: String = "",
val activityRecognition: Boolean = false,
val activityRecognitionPeriodSeconds: Int = 30,
val significantMotion: Boolean = false,
val storagePath: String? = null,
val preferences: AppPreferences = AppPreferences(),
val hasLoadedPreferences: Boolean = false,
val session: MeasurementSessionState = MeasurementSessionState.Idle,
val elapsedSeconds: Long = 0,
val isWearConnected: Boolean = false,
val message: MainMessage = MainMessage.NONE,
val keepScreenAwake: Boolean = false,
val hasLoadedPreferences: Boolean = false,
)

sealed interface MainIntent {
data class Navigate(val route: MainRoute) : MainIntent
data class ToggleSensor(val sensorId: Int) : MainIntent
data class ToggleWearSensor(val sensorId: Int) : MainIntent
data class OpenSensorDetails(val sensorType: Int?) : MainIntent
data object ToggleGps : MainIntent
data object ToggleWearGps : MainIntent
data object AdvanceOnboarding : MainIntent
data object RetreatOnboarding : MainIntent
data object CompleteOnboarding : MainIntent
data object OpenPrivacyPolicy : MainIntent
data object OpenTermsOfUse : MainIntent
data object RequestBatteryOptimizationExemption : MainIntent
data object ShareDiagnosticsText : MainIntent
data object ShareDiagnosticsFile : MainIntent
data object ChooseStorage : MainIntent
data object OpenMeasurementSetup : MainIntent
data object ReturnToSensorSelection : MainIntent
data object StartMeasurement : MainIntent
data object StopMeasurement : MainIntent
data object ClearMessage : MainIntent
data class SetCustomMeasurementName(val value: String) : MainIntent
data class SetMeasurementType(val value: String) : MainIntent
data class SetStartDelay(val seconds: Int) : MainIntent
data class SetDuration(val seconds: Int) : MainIntent
data class SetNotes(val value: String) : MainIntent
data class SetAlarmOffsets(val value: String) : MainIntent
data class SetActivityRecognition(val enabled: Boolean) : MainIntent
data class SetActivityRecognitionPeriod(val seconds: Int) : MainIntent
data class SetSignificantMotion(val enabled: Boolean) : MainIntent
data class AddAnnotation(val text: String) : MainIntent
data class SetSamplingPeriod(val index: Int) : MainIntent
data class SetLowBatteryRestriction(val enabled: Boolean) : MainIntent
data class SetWakeLock(val enabled: Boolean) : MainIntent
data class SetKeepScreenAwake(val enabled: Boolean) : MainIntent
data class SetGpsInterval(val seconds: Int) : MainIntent
data class SetGpsDistance(val meters: Int) : MainIntent
}

sealed interface MainEffect {
data object PickStorageDirectory : MainEffect
data object OpenPrivacyPolicy : MainEffect
data object OpenTermsOfUse : MainEffect
data object RequestBatteryOptimizationExemption : MainEffect
data object ShareDiagnosticsText : MainEffect
data object ShareDiagnosticsFile : MainEffect
data class RequestPermissions(val permissions: Set<String>) : MainEffect
}

data class MainNext(val state: MainState, val effect: MainEffect? = null)

object MainReducer {
fun reduce(state: MainState, intent: MainIntent): MainNext = when (intent) {
MainIntent.AdvanceOnboarding -> MainNext(state.copy(onboardingPage = state.onboardingPage + 1))
MainIntent.RetreatOnboarding -> MainNext(state.retreatOnboarding())
is MainIntent.OpenSensorDetails -> MainNext(state.openSensorDetails(intent.sensorType))
else -> reduceGeneralIntent(state, intent)
}

private fun reduceGeneralIntent(state: MainState, intent: MainIntent): MainNext = when (intent) {
MainIntent.ChooseStorage -> MainNext(state, MainEffect.PickStorageDirectory)
MainIntent.ClearMessage -> MainNext(state.copy(message = MainMessage.NONE))
MainIntent.OpenPrivacyPolicy -> MainNext(state, MainEffect.OpenPrivacyPolicy)
MainIntent.OpenTermsOfUse -> MainNext(state, MainEffect.OpenTermsOfUse)
MainIntent.RequestBatteryOptimizationExemption -> batteryOptimizationEffect(state)
MainIntent.ShareDiagnosticsText -> MainNext(state, MainEffect.ShareDiagnosticsText)
MainIntent.ShareDiagnosticsFile -> MainNext(state, MainEffect.ShareDiagnosticsFile)
MainIntent.OpenMeasurementSetup -> MainNext(state.openMeasurementSetup())
MainIntent.ReturnToSensorSelection -> MainNext(state.returnToSensorSelection())
else -> reduceSelectionIntent(state, intent)
}

private fun reduceSelectionIntent(state: MainState, intent: MainIntent): MainNext = when (intent) {
MainIntent.ToggleGps -> MainNext(state.copy(includesGps = !state.includesGps))
MainIntent.ToggleWearGps -> MainNext(state.copy(wearIncludesGps = !state.wearIncludesGps))
is MainIntent.Navigate -> MainNext(state.copy(route = intent.route))
is MainIntent.ToggleSensor -> MainNext(state.toggleSensor(intent.sensorId))
is MainIntent.ToggleWearSensor -> MainNext(state.toggleWearSensor(intent.sensorId))
else -> reduceConfigurationIntent(state, intent)
}

private fun reduceConfigurationIntent(state: MainState, intent: MainIntent): MainNext = when (intent) {
is MainIntent.SetCustomMeasurementName -> MainNext(state.copy(customMeasurementName = intent.value))
is MainIntent.SetMeasurementType -> MainNext(state.copy(measurementType = intent.value))
is MainIntent.SetStartDelay -> MainNext(state.copy(startDelaySeconds = intent.seconds.coerceAtLeast(0)))
is MainIntent.SetDuration -> MainNext(state.copy(durationSeconds = intent.seconds.coerceAtLeast(0)))
is MainIntent.SetNotes -> MainNext(state.copy(notes = intent.value))
is MainIntent.SetAlarmOffsets -> MainNext(state.copy(alarmOffsets = intent.value))
is MainIntent.SetActivityRecognition -> MainNext(state.copy(activityRecognition = intent.enabled))
is MainIntent.SetActivityRecognitionPeriod -> MainNext(state.withActivityPeriod(intent.seconds))
is MainIntent.SetSignificantMotion -> MainNext(state.copy(significantMotion = intent.enabled))
else -> MainNext(state)
}

private fun batteryOptimizationEffect(state: MainState) =
MainNext(state, MainEffect.RequestBatteryOptimizationExemption)

private fun MainState.retreatOnboarding() = copy(onboardingPage = (onboardingPage - 1).coerceAtLeast(0))

private fun MainState.openSensorDetails(sensorType: Int?) =
copy(route = MainRoute.SENSOR_DETAILS, detailsSensorType = sensorType)

private fun MainState.openMeasurementSetup() = copy(route = MainRoute.SETUP, message = MainMessage.NONE)

private fun MainState.returnToSensorSelection() = copy(route = MainRoute.RECORD, message = MainMessage.NONE)

private fun MainState.toggleSensor(sensorId: Int): MainState {
val updated = selectedSensorIds.toMutableSet()
if (!updated.add(sensorId)) updated.remove(sensorId)
return copy(selectedSensorIds = updated, message = MainMessage.NONE)
}

private fun MainState.toggleWearSensor(sensorId: Int): MainState {
val updated = selectedWearSensorIds.toMutableSet()
if (!updated.add(sensorId)) updated.remove(sensorId)
return copy(selectedWearSensorIds = updated, message = MainMessage.NONE)
}
}

private fun MainState.withActivityPeriod(seconds: Int) =
copy(activityRecognitionPeriodSeconds = seconds.coerceAtLeast(1))
Loading