timelimit-android/app/src/main/java/io/timelimit/android/ui/MainActivity.kt
2024-10-28 01:00:00 +01:00

463 lines
18 KiB
Kotlin

/*
* TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.ui
import android.Manifest
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.provider.Settings
import android.util.Log
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.*
import io.timelimit.android.Application
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.model.UserType
import io.timelimit.android.extensions.showSafe
import io.timelimit.android.integration.platform.SystemPermissionConfirmationLevel
import io.timelimit.android.integration.platform.android.NotificationChannels
import io.timelimit.android.livedata.ignoreUnchanged
import io.timelimit.android.livedata.liveDataFromNullableValue
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.u2f.U2fManager
import io.timelimit.android.u2f.protocol.U2FDevice
import io.timelimit.android.ui.animation.Transition
import io.timelimit.android.ui.login.AuthTokenLoginProcessor
import io.timelimit.android.ui.login.NewLoginFragment
import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.ActivityViewModelHolder
import io.timelimit.android.ui.main.AuthenticatedUser
import io.timelimit.android.ui.main.FragmentWithCustomTitle
import io.timelimit.android.ui.manage.device.add.AddDeviceFragment
import io.timelimit.android.ui.model.*
import io.timelimit.android.ui.overview.overview.CanNotAddDevicesInLocalModeDialogFragment
import io.timelimit.android.ui.payment.ActivityPurchaseModel
import io.timelimit.android.ui.payment.RequiresPurchaseDialogFragment
import io.timelimit.android.ui.util.SyncStatusModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.security.SecureRandom
class MainActivity : AppCompatActivity(), ActivityViewModelHolder, U2fManager.DeviceFoundListener, MainModelActivity {
companion object {
private const val LOG_TAG = "MainActivity"
private const val AUTH_DIALOG_TAG = "adt"
const val ACTION_USER_OPTIONS = "OPEN_USER_OPTIONS"
const val EXTRA_USER_ID = "userId"
private const val EXTRA_AUTH_HANDOVER = "authHandover"
private const val MAIN_MODEL_STATE = "mainModelState"
private const val FRAGMENT_IDS_STATE = "fragmentIds"
private var authHandover: Triple<Long, Long, AuthenticatedUser>? = null
fun getAuthHandoverIntent(context: Context, user: AuthenticatedUser): Intent {
val time = SystemClock.uptimeMillis()
val key = SecureRandom().nextLong()
authHandover = Triple(time, key, user)
return Intent(context, MainActivity::class.java)
.putExtra(EXTRA_AUTH_HANDOVER, key)
}
fun getAuthHandoverFromIntent(intent: Intent): AuthenticatedUser? {
val cachedHandover = authHandover
val time = SystemClock.uptimeMillis()
if (cachedHandover == null) return null
if (cachedHandover.first < time - 2000 || cachedHandover.first - 1000 > time) {
authHandover = null
return null
}
if (intent.getLongExtra(EXTRA_AUTH_HANDOVER, 0) != cachedHandover.second) return null
authHandover = null
return cachedHandover.third
}
}
private val mainModel by viewModels<MainModel>()
private val syncModel: SyncStatusModel by lazy {
ViewModelProviders.of(this).get(SyncStatusModel::class.java)
}
val purchaseModel: ActivityPurchaseModel by viewModels()
override var ignoreStop: Boolean = false
override val showPasswordRecovery: Boolean = true
private val requestNotifyPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) mainModel.reportPermissionsChanged()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val isNightMode =
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(
if (isNightMode) android.graphics.Color.TRANSPARENT
else resources.getColor(R.color.colorPrimaryDark)
)
)
supportActionBar!!.hide()
U2fManager.setupActivity(this)
NotificationChannels.createNotificationChannels(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager, this)
if (savedInstanceState != null) {
mainModel.state.value = savedInstanceState.getSerializable(MAIN_MODEL_STATE) as State
mainModel.fragmentIds.addAll(savedInstanceState.getIntegerArrayList(FRAGMENT_IDS_STATE) ?: emptyList())
}
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
for (message in mainModel.activityCommand) when (message) {
ActivityCommand.ShowAddDeviceFragment -> AddDeviceFragment().show(supportFragmentManager)
ActivityCommand.ShowCanNotAddDevicesInLocalModeDialogFragment -> CanNotAddDevicesInLocalModeDialogFragment().show(supportFragmentManager)
ActivityCommand.ShowAuthenticationScreen -> showAuthenticationScreen()
ActivityCommand.ShowMissingPremiumDialog -> RequiresPurchaseDialogFragment().show(supportFragmentManager)
is ActivityCommand.LaunchSystemSettings -> mainModel.logic.platformIntegration.openSystemPermissionScren(
this@MainActivity, message.permission, SystemPermissionConfirmationLevel.Suggestion
)
is ActivityCommand.TriggerUninstall -> try {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:${message.packageName}")
).addCategory(Intent.CATEGORY_DEFAULT)
else Intent(
Intent.ACTION_UNINSTALL_PACKAGE,
Uri.parse("package:${message.packageName}")
)
startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} catch (ex: Exception) {
message.errorHandler()
}
ActivityCommand.RequestNotifyPermission -> requestNotifyPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
// init the purchaseModel
purchaseModel.getApplication<Application>()
// init if not yet done
DefaultAppLogic.with(this)
val fragments = MutableStateFlow(emptyMap<Int, Fragment>())
supportFragmentManager.registerFragmentLifecycleCallbacks(object: FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentStarted(fm: FragmentManager, f: Fragment) {
super.onFragmentStarted(fm, f)
fragments.update {
it + Pair(f.id, f)
}
}
override fun onFragmentStopped(fm: FragmentManager, f: Fragment) {
super.onFragmentStopped(fm, f)
fragments.update {
it - f.id
}
if (f is NewLoginFragment) mainModel.reportAuthenticationScreenClosed()
cleanupFragments()
}
}, false)
handleParameters(intent)
val hasDeviceId = getActivityViewModel().logic.deviceId.map { it != null }.ignoreUnchanged()
val hasParentKey = getActivityViewModel().logic.database.config().getParentModeKeyLive().map { it != null }.ignoreUnchanged()
hasDeviceId.observe(this) {
val rootDestination = mainModel.state.value.first()
if (!it) getActivityViewModel().logOut()
if (
it && rootDestination !is State.Overview ||
!it && rootDestination is State.Overview
) {
restartContent()
}
}
hasParentKey.observe(this) {
val rootDestination = mainModel.state.value.first()
if (
it && rootDestination !is State.ParentMode ||
!it && rootDestination is State.ParentMode
) {
restartContent()
}
}
setContent {
Theme {
val screenLive by mainModel.screen.collectAsState(initial = null)
val subtitleLive by syncModel.statusText.asFlow().collectAsState(initial = null)
val isAuthenticated by getActivityViewModel().authenticatedUser
.map { it?.second?.type == UserType.Parent }
.asFlow().collectAsState(initial = false)
BackHandler(enabled = screenLive?.state?.previous != null) {
execute(UpdateStateCommand.BackToPreviousScreen)
}
updateTransition(
targetState = screenLive,
label = "AnimatedContent"
).AnimatedContent(
modifier = Modifier.background(Color.Black),
contentKey = { screen ->
when (screen) {
is Screen.FragmentScreen ->
if (screen::class.java == Screen.FragmentScreen::class.java) screen.fragment.containerId
else screen.javaClass
null -> null
else -> screen.javaClass
}
},
transitionSpec = {
val from = initialState
val to = targetState
if (from == null || to == null) Transition.none
else {
val isOpening = to.state.hasPrevious(from.state)
val isClosing = from.state.hasPrevious(to.state)
if (isOpening) Transition.openScreen
else if (isClosing)
if (from.state.previous == to.state) Transition.closeScreen
else Transition.bigCloseScreen
else Transition.swap
}
}
) { screen ->
val showAuthenticationDialog =
if (screen is ScreenWithAuthenticationFab && !isAuthenticated) ::showAuthenticationScreen
else null
val fragmentsLive by fragments.collectAsState()
val customTitle by when (screen) {
is Screen.FragmentScreen -> {
when (val fragment = fragmentsLive[screen.fragment.containerId]) {
is FragmentWithCustomTitle -> fragment.getCustomTitle()
else -> liveDataFromNullableValue(null)
}
}
else -> liveDataFromNullableValue(null)
}.asFlow().collectAsState(initial = null)
val screenTitle = when (screen) {
is ScreenWithTitle -> when (val title = screen.title) {
is Title.Plain -> title.text
is Title.StringResource -> stringResource(title.id)
}
else -> null
}
ScreenScaffold(
screen = screen,
title = screenTitle ?: customTitle ?: stringResource(R.string.app_name),
subtitle = subtitleLive,
backStack = when (screen) {
is ScreenWithBackStack -> screen.backStack
else -> emptyList()
},
executeCommand = ::execute,
content = { paddingValues ->
ScreenMultiplexer(
screen = screen,
fragmentManager = supportFragmentManager,
fragmentIds = mainModel.fragmentIds,
modifier = Modifier.fillMaxSize(),
paddingValues = paddingValues
)
},
showAuthenticationDialog = showAuthenticationDialog,
snackbarHostState = when (screen) {
is ScreenWithSnackbar -> screen.snackbarHostState
else -> null
}
)
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putSerializable(MAIN_MODEL_STATE, mainModel.state.value)
outState.putIntegerArrayList(FRAGMENT_IDS_STATE, ArrayList(mainModel.fragmentIds))
}
override fun onStart() {
super.onStart()
purchaseModel.queryAndProcessPurchasesAsync()
IsAppInForeground.reportStart()
syncModel.handleStart()
}
override fun onStop() {
super.onStop()
if ((!isChangingConfigurations) && (!ignoreStop)) {
getActivityViewModel().logOut()
}
IsAppInForeground.reportStop()
}
override fun onDestroy() {
super.onDestroy()
purchaseModel.forgetActivityCheckout()
}
private fun handleParameters(intent: Intent?): Boolean {
// do not return true in this case because this should not affect other navigations
intent?.also {
getAuthHandoverFromIntent(intent)?.also { auth ->
getActivityViewModel().setAuthenticatedUser(auth)
}
}
if (intent?.action == ACTION_USER_OPTIONS) {
val userId = intent.getStringExtra(EXTRA_USER_ID)
val valid = userId != null && try { IdGenerator.assertIdValid(userId); true } catch (ex: IllegalArgumentException) {false}
if (userId != null && valid) {
execute(UpdateStateCommand.RecoverPassword(userId))
}
}
return false
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.flags and Intent.FLAG_ACTIVITY_REORDER_TO_FRONT == Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) {
return
}
if (handleParameters(intent)) return
}
private fun restartContent() {
mainModel.execute(UpdateStateCommand.Reset)
}
override fun getActivityViewModel(): ActivityViewModel = mainModel.activityModel
override fun showAuthenticationScreen() {
if (supportFragmentManager.findFragmentByTag(AUTH_DIALOG_TAG) == null) {
NewLoginFragment().showSafe(supportFragmentManager, AUTH_DIALOG_TAG)
}
}
private fun cleanupFragments() {
mainModel.fragmentIds
.filter { fragmentId ->
var v = mainModel.state.value as State?
while (v != null) {
if (v is FragmentState && v.containerId == fragmentId) return@filter false
v = v.previous
}
true
}
.mapNotNull { supportFragmentManager.findFragmentById(it) }
.filter { it.isDetached }
.forEach {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "remove fragment $it")
}
val id = it.id
supportFragmentManager.beginTransaction()
.remove(it)
.commitAllowingStateLoss()
mainModel.fragmentIds.remove(id)
}
}
override fun onResume() {
super.onResume()
U2fManager.with(this).registerListener(this)
}
override fun onPause() {
super.onPause()
U2fManager.with(this).unregisterListener(this)
}
override fun onDeviceFound(device: U2FDevice) = AuthTokenLoginProcessor.process(device, getActivityViewModel())
override fun execute(command: UpdateStateCommand) = mainModel.execute(command)
}