Add account deletion option

This commit is contained in:
Jonas Lochmann 2023-04-03 02:00:00 +02:00
parent 32652e2890
commit 0829e4e440
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
24 changed files with 1025 additions and 26 deletions

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2023 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
@ -56,6 +56,7 @@ interface ServerApi {
suspend fun removeDevice(deviceAuthToken: String, parentUserId: String, parentPasswordSecondHash: String, deviceId: String)
suspend fun isDeviceRemoved(deviceAuthToken: String): Boolean
suspend fun createIdentityToken(deviceAuthToken: String, parentUserId: String, parentPasswordSecondHash: String): String
suspend fun requestAccountDeletion(deviceAuthToken: String, mailAuthTokens: List<String>)
}
class MailServerBlacklistedException: RuntimeException()

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2023 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
@ -106,4 +106,11 @@ class DummyServerApi: ServerApi {
): String {
throw IOException()
}
override suspend fun requestAccountDeletion(
deviceAuthToken: String,
mailAuthTokens: List<String>
) {
throw IOException()
}
}

View file

@ -42,8 +42,8 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
private const val LOG_TAG = "HttpServerApi"
private const val DEVICE_AUTH_TOKEN = "deviceAuthToken"
private const val GOOGLE_AUTH_TOKEN = "googleAuthToken"
private const val MAIL_AUTH_TOKEN = "mailAuthToken"
private const val MAIL_AUTH_TOKENS = "mailAuthTokens"
private const val REGISTER_TOKEN = "registerToken"
private const val MILLISECONDS = "ms"
private const val STATUS = "status"
@ -607,6 +607,24 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
}
}
override suspend fun requestAccountDeletion(
deviceAuthToken: String,
mailAuthTokens: List<String>
) {
postJsonRequest("parent/delete-account") { writer ->
writer.beginObject()
writer.name(DEVICE_AUTH_TOKEN).value(deviceAuthToken)
writer.name(MAIL_AUTH_TOKENS).beginArray()
mailAuthTokens.forEach { writer.value(it) }
writer.endArray()
writer.endObject()
}.use { response ->
response.assertSuccess()
}
}
private suspend fun postJsonRequest(
path: String,
requestBody: (writer: JsonWriter) -> Unit

View file

@ -18,6 +18,7 @@ package io.timelimit.android.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.fragment.app.FragmentManager
import io.timelimit.android.ui.account.DeleteRegistrationScreen
import io.timelimit.android.ui.diagnose.deviceowner.DeviceOwnerScreen
import io.timelimit.android.ui.manage.device.manage.permission.ManageDevicePermissionScreen
import io.timelimit.android.ui.manage.device.manage.user.ManageDeviceUserScreen
@ -46,5 +47,6 @@ fun ScreenMultiplexer(
is Screen.SetupConnectModePrivacyScreen -> SetupConnectedModePrivacyScreen(screen.customServerDomain, screen.accept, modifier)
is Screen.SetupSelectConnectedModeScreen -> SelectConnectedModeScreen(mailLogin = screen.mailLogin, codeLogin = screen.codeLogin, modifier = modifier)
is Screen.SetupSelectModeScreen -> SelectModeScreen(selectLocal = screen.selectLocal, selectConnected = screen.selectConnected, selectUninstall = screen.selectUninstall, modifier = modifier)
is Screen.DeleteRegistration -> DeleteRegistrationScreen(screen.content, modifier)
}
}

View file

@ -0,0 +1,103 @@
/*
* TimeLimit Copyright <C> 2019 - 2023 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.account
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.timelimit.android.R
import io.timelimit.android.ui.authentication.AuthenticateByMailScreen
import io.timelimit.android.ui.diagnose.exception.DiagnoseExceptionDialog
import io.timelimit.android.ui.model.account.AccountDeletion
import io.timelimit.android.ui.view.SwitchRow
@Composable
fun DeleteRegistrationScreen(
content: AccountDeletion.MyScreen,
modifier: Modifier
) {
when (content) {
is AccountDeletion.MyScreen.NoAccount -> DeleteCenteredText(
stringResource(R.string.account_deletion_text_no_account),
modifier
)
is AccountDeletion.MyScreen.MailConfirmation -> AuthenticateByMailScreen(content.content, modifier)
is AccountDeletion.MyScreen.FinalConfirmation -> DeleteFinalConfirmationScreen(content, modifier)
is AccountDeletion.MyScreen.Done -> DeleteCenteredText(
stringResource(R.string.account_deletion_text_done),
modifier
)
}
}
@Composable
fun DeleteCenteredText(
text: String,
modifier: Modifier
) {
Box(
modifier,
contentAlignment = Alignment.CenterStart
) {
Text(
text,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
)
}
}
@Composable
fun DeleteFinalConfirmationScreen(
content: AccountDeletion.MyScreen.FinalConfirmation,
modifier: Modifier
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(stringResource(R.string.account_deletion_confirmation_text))
SwitchRow(
label = stringResource(R.string.account_deletion_confirmation_premium_toggle),
checked = content.didConfirmPremiumLoss,
onCheckedChange = content.actions?.updateConfirmPremiumLoss ?: {},
enabled = content.actions?.updateConfirmPremiumLoss != null
)
Button(
onClick = content.actions?.finalConfirmation ?: {},
enabled = content.actions?.finalConfirmation != null,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error)
) {
Text(stringResource(R.string.account_deletion_confirmation_button))
}
}
if (content.errorDialog != null) DiagnoseExceptionDialog(content.errorDialog.message, content.errorDialog.close)
}

View file

@ -0,0 +1,108 @@
/*
* TimeLimit Copyright <C> 2019 - 2023 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.authentication
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.timelimit.android.R
import io.timelimit.android.ui.diagnose.exception.DiagnoseExceptionDialog
import io.timelimit.android.ui.diagnose.exception.SimpleErrorDialog
import io.timelimit.android.ui.model.mailauthentication.MailAuthentication
import io.timelimit.android.ui.view.EnterTextField
@Composable
fun AuthenticateByMailScreen(
content: MailAuthentication.Screen,
modifier: Modifier
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
when (content) {
is MailAuthentication.Screen.ConfirmMailSending -> {
Text(stringResource(R.string.authenticate_by_mail_hardcoded_text, content.mail))
Button(
onClick = content.confirm ?: {},
enabled = content.confirm != null,
modifier = Modifier.align(Alignment.End)
) {
Text(stringResource(R.string.generic_go))
}
if (content.error != null) AuthenticateByMailError(content.error.dialog, content.error.close)
}
is MailAuthentication.Screen.EnterReceivedCode -> {
Text(stringResource(R.string.authenticate_by_mail_message_sent, content.mail))
EnterTextField(
label = { Text(stringResource(R.string.authenticate_by_mail_hint_code)) },
value = content.codeInput,
onValueChange = content.actions?.updateCodeInput ?: {},
onConfirmInput = content.actions?.confirmCodeInput ?: {},
enabled = content.actions != null,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = content.actions?.confirmCodeInput ?: {},
enabled = content.actions != null,
modifier = Modifier.align(Alignment.End)
) {
Text(stringResource(R.string.generic_ok))
}
if (content.error != null) AuthenticateByMailError(content.error.dialog, content.error.close)
}
}
}
}
@Composable
fun AuthenticateByMailError(error: MailAuthentication.ErrorDialog, close: () -> Unit) {
when (error) {
MailAuthentication.ErrorDialog.RateLimit -> SimpleErrorDialog(
null,
stringResource(R.string.authenticate_too_many_requests_text),
close
)
MailAuthentication.ErrorDialog.BlockedMailServer -> SimpleErrorDialog(
stringResource(R.string.authenticate_blacklisted_mail_server_title),
stringResource(R.string.authenticate_blacklisted_mail_server_text),
close
)
MailAuthentication.ErrorDialog.MailAddressNotAllowed -> SimpleErrorDialog(
stringResource(R.string.authenticate_not_whitelisted_address_title),
stringResource(R.string.authenticate_not_whitelisted_address_text),
close
)
is MailAuthentication.ErrorDialog.ExceptionDetails -> DiagnoseExceptionDialog(error.message, close)
}
}

View file

@ -32,6 +32,7 @@ import io.timelimit.android.livedata.liveDataFromFunction
import io.timelimit.android.livedata.liveDataFromNullableValue
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.websocket.networkstatus.NetworkStatus
import io.timelimit.android.ui.diagnose.exception.DiagnoseExceptionDialogFragment
import io.timelimit.android.ui.main.FragmentWithCustomTitle
class DiagnoseConnectionFragment : Fragment(), FragmentWithCustomTitle {

View file

@ -27,6 +27,7 @@ import io.timelimit.android.databinding.FragmentDiagnoseMainBinding
import io.timelimit.android.livedata.liveDataFromNonNullValue
import io.timelimit.android.livedata.liveDataFromNullableValue
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.ui.diagnose.exception.DiagnoseExceptionDialogFragment
import io.timelimit.android.ui.main.ActivityViewModelHolder
import io.timelimit.android.ui.main.AuthenticationFab
import io.timelimit.android.ui.main.FragmentWithCustomTitle

View file

@ -34,6 +34,7 @@ import io.timelimit.android.databinding.DiagnoseSyncFragmentBinding
import io.timelimit.android.livedata.liveDataFromNullableValue
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.apply.UploadActionsUtil
import io.timelimit.android.ui.diagnose.exception.DiagnoseExceptionDialogFragment
import io.timelimit.android.ui.main.FragmentWithCustomTitle
class DiagnoseSyncFragment : Fragment(), FragmentWithCustomTitle {

View file

@ -0,0 +1,47 @@
/*
* TimeLimit Copyright <C> 2019 - 2023 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.diagnose.exception
import androidx.compose.material.AlertDialog
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import io.timelimit.android.R
import io.timelimit.android.util.Clipboard
@Composable
fun DiagnoseExceptionDialog(message: String, close: () -> Unit) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = close,
text = { Text(message) },
confirmButton = {
TextButton(onClick = close) {
Text(stringResource(R.string.generic_ok))
}
},
dismissButton = {
TextButton(onClick = {
Clipboard.setAndToast(context, message)
}) {
Text(stringResource(R.string.diagnose_sync_copy_to_clipboard))
}
}
)
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2023 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
@ -13,7 +13,7 @@
* 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.diagnose
package io.timelimit.android.ui.diagnose.exception
import android.app.Dialog
import android.os.Bundle
@ -23,8 +23,6 @@ import androidx.fragment.app.FragmentManager
import io.timelimit.android.R
import io.timelimit.android.extensions.showSafe
import io.timelimit.android.util.Clipboard
import java.io.PrintWriter
import java.io.StringWriter
class DiagnoseExceptionDialogFragment: DialogFragment() {
companion object {
@ -36,19 +34,10 @@ class DiagnoseExceptionDialogFragment: DialogFragment() {
putSerializable(EXCEPTION, exception)
}
}
fun getStackTraceString(tr: Throwable): String = StringWriter().let { sw ->
PrintWriter(sw).let { pw ->
tr.printStackTrace(pw)
pw.flush()
}
sw.toString()
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val message = getStackTraceString(requireArguments().getSerializable(EXCEPTION) as Exception)
val message = ExceptionUtil.format(requireArguments().getSerializable(EXCEPTION) as Exception)
return AlertDialog.Builder(requireContext(), theme)
.setMessage(message)

View file

@ -0,0 +1,30 @@
/*
* TimeLimit Copyright <C> 2019 - 2023 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.diagnose.exception
import java.io.PrintWriter
import java.io.StringWriter
object ExceptionUtil {
fun format(tr: Throwable): String = StringWriter().let { sw ->
PrintWriter(sw).let { pw ->
tr.printStackTrace(pw)
pw.flush()
}
sw.toString()
}
}

View file

@ -0,0 +1,37 @@
/*
* TimeLimit Copyright <C> 2019 - 2023 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.diagnose.exception
import androidx.compose.material.AlertDialog
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.timelimit.android.R
@Composable
fun SimpleErrorDialog(title: String?, message: String, close: () -> Unit) {
AlertDialog(
onDismissRequest = close,
title = if (title != null) ({ Text(title) }) else null,
text = { Text(message) },
confirmButton = {
TextButton(onClick = close) {
Text(stringResource(R.string.generic_ok))
}
}
)
}

View file

@ -23,6 +23,7 @@ import io.timelimit.android.R
import io.timelimit.android.data.model.UserType
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.model.account.AccountDeletion
import io.timelimit.android.ui.model.diagnose.DeviceOwnerHandling
import io.timelimit.android.ui.model.flow.Case
import io.timelimit.android.ui.model.flow.splitConflated
@ -114,6 +115,7 @@ class MainModel(application: Application): AndroidViewModel(application) {
Case.simple<_, _, State.ManageDevice> { state -> ManageDeviceHandling.processState(logic, activityCommandInternal, authenticationModelApi, state, updateMethod(::updateState)) },
Case.simple<_, _, State.DiagnoseScreen.DeviceOwner> { DeviceOwnerHandling.processState(logic, scope, authenticationModelApi, state) },
Case.simple<_, _, State.Setup> { state -> SetupHandling.handle(logic, activityCommandInternal, state, updateMethod(::updateState)) },
Case.simple<_, _, State.DeleteAccount> { AccountDeletion.handle(logic, scope, share(it), updateMethod(::updateState)) },
Case.simple<_, _, FragmentState> { state ->
state.transform {
val containerId = it.containerId ?: run {

View file

@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.outlined.Info
import io.timelimit.android.R
import io.timelimit.android.ui.manage.device.manage.permission.PermissionScreenContent
import io.timelimit.android.ui.model.account.AccountDeletion
import io.timelimit.android.ui.model.diagnose.DeviceOwnerHandling
import io.timelimit.android.ui.model.main.OverviewHandling
import io.timelimit.android.ui.model.managedevice.ManageDeviceUser
@ -49,10 +50,16 @@ sealed class Screen(
R.string.main_tab_about,
UpdateStateCommand.Overview.LaunchAbout
)),
listOf(Menu.Dropdown(
listOf(
Menu.Dropdown(
R.string.main_tab_uninstall,
UpdateStateCommand.Overview.Uninstall
))
),
Menu.Dropdown(
R.string.account_deletion_title,
UpdateStateCommand.Overview.DeleteAccount
)
)
), ScreenWithAuthenticationFab, ScreenWithSnackbar
class ManageChildScreen(
@ -252,6 +259,14 @@ sealed class Screen(
val mailLogin: () -> Unit,
val codeLogin: () -> Unit
): Screen(state)
class DeleteRegistration(
state: State.DeleteAccount,
val content: AccountDeletion.MyScreen,
override val snackbarHostState: SnackbarHostState
): Screen(state), ScreenWithSnackbar, ScreenWithTitle {
override val title: Title = Title.StringResource(R.string.account_deletion_title)
}
}
interface ScreenWithAuthenticationFab

View file

@ -46,6 +46,7 @@ import io.timelimit.android.ui.manage.parent.password.restore.RestoreParentPassw
import io.timelimit.android.ui.manage.parent.password.restore.RestoreParentPasswordFragmentArgs
import io.timelimit.android.ui.manage.parent.u2fkey.ManageParentU2FKeyFragment
import io.timelimit.android.ui.manage.parent.u2fkey.ManageParentU2FKeyFragmentArgs
import io.timelimit.android.ui.model.account.AccountDeletion
import io.timelimit.android.ui.model.diagnose.DeviceOwnerHandling
import io.timelimit.android.ui.model.main.OverviewHandling
import io.timelimit.android.ui.overview.uninstall.UninstallFragment
@ -236,6 +237,10 @@ sealed class State (val previous: State?): Serializable {
}
}
class SetupDevice(val previousOverview: Overview): FragmentStateLegacy(previous = previousOverview, fragmentClass = SetupDeviceFragment::class.java)
data class DeleteAccount(
val previousOverview: Overview,
val content: AccountDeletion.MyState = AccountDeletion.MyState.Preparing()
): State(previousOverview)
class Uninstall(previous: Overview): FragmentStateLegacy(previous = previous, fragmentClass = UninstallFragment::class.java)
object DiagnoseScreen {
class Main(previous: About): FragmentStateLegacy(previous, DiagnoseMainFragment::class.java)

View file

@ -83,6 +83,12 @@ sealed class UpdateStateCommand {
else null
}
object DeleteAccount: UpdateStateCommand() {
override fun transform(state: State): State? =
if (state is State.Overview) State.DeleteAccount(state)
else null
}
object ShowAllUsers: UpdateStateCommand() {
override fun transform(state: State): State? =
if (state is State.Overview) state.copy(

View file

@ -0,0 +1,241 @@
/*
* TimeLimit Copyright <C> 2019 - 2023 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.model.account
import androidx.compose.material.SnackbarDuration
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.SnackbarResult
import androidx.lifecycle.asFlow
import io.timelimit.android.R
import io.timelimit.android.data.model.UserType
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.sync.network.api.GoneHttpError
import io.timelimit.android.sync.network.api.HttpError
import io.timelimit.android.ui.diagnose.exception.ExceptionUtil
import io.timelimit.android.ui.model.Screen
import io.timelimit.android.ui.model.State
import io.timelimit.android.ui.model.flow.Case
import io.timelimit.android.ui.model.flow.splitConflated
import io.timelimit.android.ui.model.mailauthentication.MailAuthentication
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.io.IOException
import java.io.Serializable
object AccountDeletion {
sealed class MyState: Serializable {
data class Preparing(
val mailToAuthToken: Map<String, String> = emptyMap(),
val didConfirmPremiumLoss: Boolean = false,
val currentMailAuthentication: Map<String, MailAuthentication.State> = emptyMap(),
val errorDialog: String? = null
): MyState()
object Done: MyState()
}
sealed class MyScreen {
object NoAccount: MyScreen()
class MailConfirmation(val content: MailAuthentication.Screen): MyScreen()
class FinalConfirmation(
val didConfirmPremiumLoss: Boolean,
val actions: Actions?,
val errorDialog: ErrorDialog?
): MyScreen() {
data class Actions(
val updateConfirmPremiumLoss: (Boolean) -> Unit,
val finalConfirmation: (() -> Unit)?
)
data class ErrorDialog(val message: String, val close: () -> Unit)
}
object Done: MyScreen()
}
internal sealed class ScreenOrMail {
data class Screen(val screen: MyScreen): ScreenOrMail()
data class Mail(val mail: String): ScreenOrMail()
}
fun handle(
logic: AppLogic,
scope: CoroutineScope,
stateLive: SharedFlow<State.DeleteAccount>,
updateState: ((State.DeleteAccount) -> State) -> Unit
): Flow<Screen> {
val snackbarHostState = SnackbarHostState()
val screenLive = handleInternal(
logic,
snackbarHostState,
scope,
stateLive.map { it.content }
) { modifier -> updateState { oldState ->
oldState.copy(content = modifier(oldState.content))
} }
return combine(stateLive, screenLive) { state, screen ->
Screen.DeleteRegistration(state, screen, snackbarHostState)
}
}
private fun handleInternal(
logic: AppLogic,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope,
stateLive: Flow<MyState>,
updateState: ((MyState) -> MyState) -> Unit
): Flow<MyScreen> = stateLive.splitConflated(
Case.simple<_, _, MyState.Preparing> {
handlePreparing(
logic,
snackbarHostState,
scope,
share(it),
updateMethod(updateState)
)
},
Case.simple<_, _, MyState.Done> { flowOf(MyScreen.Done) },
)
@OptIn(ExperimentalCoroutinesApi::class)
private fun handlePreparing(
logic: AppLogic,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope,
stateLive: SharedFlow<MyState.Preparing>,
updateState: ((MyState.Preparing) -> MyState) -> Unit
): Flow<MyScreen> {
var lastErrorJob: Job? = null
val isWorkingLive = MutableStateFlow(false)
val isLocalModeLive = logic.fullVersion.isLocalMode.asFlow()
val linkedParentMailAddressesLive = logic.database.user().getAllUsersFlow().map { users ->
users
.filter { it.type == UserType.Parent }
.map { it.mail }
.filter { it.isNotEmpty() }
.toSet()
}
return combine(
stateLive, isLocalModeLive, linkedParentMailAddressesLive, isWorkingLive
) { state, isLocalMode, linkedParentMailAddresses, isWorking ->
val remainingMailAddresses = linkedParentMailAddresses - state.mailToAuthToken.keys
val remainingMailAddress = remainingMailAddresses.minOrNull()
if (isLocalMode) ScreenOrMail.Screen(MyScreen.NoAccount)
else if (remainingMailAddress != null) ScreenOrMail.Mail(remainingMailAddress)
else {
val finalConfirmation: () -> Unit = { scope.launch {
lastErrorJob?.cancel()
if (isWorkingLive.compareAndSet(expect = false, update = true)) try {
val serverConfiguration = logic.serverLogic.getServerConfigCoroutine()
serverConfiguration.api.requestAccountDeletion(
deviceAuthToken = serverConfiguration.deviceAuthToken,
mailAuthTokens = state.mailToAuthToken.values.toList()
)
updateState { MyState.Done }
} catch (ex: GoneHttpError) {
lastErrorJob = scope.launch {
snackbarHostState.showSnackbar(
logic.context.getString(R.string.error_server_client_deprecated)
)
}
} catch (ex: Exception) {
lastErrorJob = scope.launch {
val result = snackbarHostState.showSnackbar(
logic.context.getString(
when (ex) {
is HttpError -> R.string.error_server_rejected
is IOException -> R.string.error_network
else -> R.string.error_general
}
),
actionLabel = logic.context.getString(R.string.generic_show_details),
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) updateState {
it.copy(errorDialog = ExceptionUtil.format(ex))
}
}
} finally {
isWorkingLive.value = false
}
} }
ScreenOrMail.Screen(MyScreen.FinalConfirmation(
didConfirmPremiumLoss = state.didConfirmPremiumLoss,
actions = if (isWorking) null else MyScreen.FinalConfirmation.Actions(
updateConfirmPremiumLoss = { updateState { oldState -> oldState.copy(didConfirmPremiumLoss = it) } },
finalConfirmation = if (state.didConfirmPremiumLoss) finalConfirmation else null
),
errorDialog = state.errorDialog?.let { message ->
MyScreen.FinalConfirmation.ErrorDialog(
message = message,
close = { updateState { it.copy(errorDialog = null) } }
)
}
))
}
}.distinctUntilChanged().transformLatest { screenOrMail ->
when (screenOrMail) {
is ScreenOrMail.Screen -> emit(screenOrMail.screen)
is ScreenOrMail.Mail -> emitAll(
MailAuthentication.handle(
logic,
scope,
snackbarHostState,
stateLive.map { state ->
state.currentMailAuthentication[screenOrMail.mail] ?: MailAuthentication.State.ConfirmMailSending(screenOrMail.mail)
},
updateState = { modifier ->
updateState { oldState ->
oldState.copy(
currentMailAuthentication = oldState.currentMailAuthentication + Pair(
screenOrMail.mail,
modifier(
oldState.currentMailAuthentication[screenOrMail.mail] ?: MailAuthentication.State.ConfirmMailSending(screenOrMail.mail)
)
)
)
}
},
processAuthToken = { mailAuthToken ->
updateState { oldState ->
oldState.copy(
mailToAuthToken = oldState.mailToAuthToken + Pair(screenOrMail.mail, mailAuthToken)
)
}
}
).map { MyScreen.MailConfirmation(it) }
)
}
}
}
}

View file

@ -0,0 +1,235 @@
/*
* TimeLimit Copyright <C> 2019 - 2023 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.model.mailauthentication
import androidx.compose.material.SnackbarDuration
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.SnackbarResult
import io.timelimit.android.R
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.sync.network.api.*
import io.timelimit.android.ui.diagnose.exception.ExceptionUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.io.IOException
object MailAuthentication {
sealed class State: java.io.Serializable {
sealed class InitialState: State()
data class ConfirmMailSending(val mail: String, val error: ErrorDialog? = null): InitialState()
data class EnterReceivedCode(
val mail: String,
val serverToken: String,
val codeInput: String,
val error: ErrorDialog?,
val initialState: InitialState
): State()
}
sealed class Screen {
class ConfirmMailSending(
val mail: String,
val error: Error?,
val confirm: (() -> Unit)?
): Screen()
class EnterReceivedCode(
val mail: String,
val codeInput: String,
val error: Error?,
val actions: Actions?
): Screen() {
class Actions(
val updateCodeInput: (String) -> Unit,
val confirmCodeInput: () -> Unit
)
}
data class Error(val dialog: ErrorDialog, val close: () -> Unit)
}
sealed class ErrorDialog {
object RateLimit: ErrorDialog()
object BlockedMailServer: ErrorDialog()
object MailAddressNotAllowed: ErrorDialog()
data class ExceptionDetails(val message: String): ErrorDialog()
}
fun handle(
logic: AppLogic,
scope: CoroutineScope,
snackbarHostState: SnackbarHostState,
stateLive: Flow<State>,
updateState: ((State) -> State) -> Unit,
processAuthToken: suspend (String) -> Unit
): Flow<Screen> {
var lastErrorJob: Job? = null
val isWorkingLive = MutableStateFlow(false)
return combine(stateLive, isWorkingLive) { state, isWorking ->
when (state) {
is State.ConfirmMailSending -> {
val update: ((State.ConfirmMailSending) -> State) -> Unit = { modifier ->
updateState { oldState ->
if (oldState is State.ConfirmMailSending) modifier(oldState)
else oldState
}
}
val confirm: () -> Unit = { scope.launch {
if (isWorkingLive.compareAndSet(expect = false, update = true)) try {
lastErrorJob?.cancel()
val serverConfiguration = logic.serverLogic.getServerConfigCoroutine()
val serverToken = serverConfiguration.api.sendMailLoginCode(
mail = state.mail,
locale = logic.context.resources.configuration.locale.language,
deviceAuthToken = serverConfiguration.deviceAuthToken.ifEmpty { null }
)
update { State.EnterReceivedCode(
mail = state.mail,
serverToken = serverToken,
codeInput = "",
error = null,
initialState = State.ConfirmMailSending(mail = state.mail, error = null)
) }
} catch (ex: TooManyRequestsHttpError) {
update { it.copy(error = ErrorDialog.RateLimit) }
} catch (ex: MailServerBlacklistedException) {
update { it.copy(error = ErrorDialog.BlockedMailServer) }
} catch (ex: MailAddressNotWhitelistedException) {
update { it.copy(error = ErrorDialog.MailAddressNotAllowed) }
} catch (ex: Exception) {
lastErrorJob = scope.launch {
val result = snackbarHostState.showSnackbar(
logic.context.getString(
when (ex) {
is HttpError -> R.string.error_server_rejected
is IOException -> R.string.error_network
else -> R.string.error_general
}
),
actionLabel = logic.context.getString(R.string.generic_show_details),
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
val message = ExceptionUtil.format(ex)
update { it.copy(error = ErrorDialog.ExceptionDetails(message)) }
}
}
} finally {
isWorkingLive.value = false
}
} }
val error = state.error?.let {
Screen.Error(it) { update { it.copy(error = null) } }
}
Screen.ConfirmMailSending(
mail = state.mail,
error = error,
confirm = if (isWorking) null else confirm
)
}
is State.EnterReceivedCode -> {
val update: ((State.EnterReceivedCode) -> State) -> Unit = { modifier ->
updateState { oldState ->
if (oldState is State.EnterReceivedCode) modifier(oldState)
else oldState
}
}
val actions = Screen.EnterReceivedCode.Actions(
updateCodeInput = { code ->
if (!isWorkingLive.value) update { it.copy(codeInput = code) }
},
confirmCodeInput = { scope.launch {
if (isWorkingLive.compareAndSet(expect = false, update = true)) try {
lastErrorJob?.cancel()
val serverConfiguration = logic.serverLogic.getServerConfigCoroutine()
val authToken = serverConfiguration.api.signInByMailCode(
mailLoginToken = state.serverToken,
code = state.codeInput
)
processAuthToken(authToken)
} catch (ex: ForbiddenHttpError) {
lastErrorJob = scope.launch {
snackbarHostState.showSnackbar(
logic.context.getString(R.string.authenticate_by_mail_snackbar_wrong_code)
)
}
} catch (ex: GoneHttpError) {
snackbarHostState.showSnackbar(
logic.context.getString(R.string.authenticate_by_mail_snackbar_wrong_code)
)
// go back to first step
update { it.initialState }
} catch (ex: Exception) {
lastErrorJob = scope.launch {
val result = snackbarHostState.showSnackbar(
logic.context.getString(
when (ex) {
is HttpError -> R.string.error_server_rejected
is IOException -> R.string.error_network
else -> R.string.error_general
}
),
actionLabel = logic.context.getString(R.string.generic_show_details),
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
val message = ExceptionUtil.format(ex)
update { it.copy(error = ErrorDialog.ExceptionDetails(message)) }
}
}
} finally {
isWorkingLive.value = false
}
} }
)
val error = state.error?.let {
Screen.Error(it) { update { it.copy(error = null) } }
}
Screen.EnterReceivedCode(
mail = state.mail,
codeInput = state.codeInput,
error = error,
actions = if (isWorking) null else actions
)
}
}
}
}
}

View file

@ -27,7 +27,7 @@ import io.timelimit.android.databinding.FragmentPurchaseBinding
import io.timelimit.android.livedata.liveDataFromNullableValue
import io.timelimit.android.livedata.mergeLiveData
import io.timelimit.android.ui.MainActivity
import io.timelimit.android.ui.diagnose.DiagnoseExceptionDialogFragment
import io.timelimit.android.ui.diagnose.exception.DiagnoseExceptionDialogFragment
import io.timelimit.android.ui.main.ActivityViewModelHolder
import io.timelimit.android.ui.main.FragmentWithCustomTitle
import io.timelimit.android.ui.main.getActivityViewModel

View file

@ -0,0 +1,53 @@
/*
* TimeLimit Copyright <C> 2019 - 2023 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.view
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.*
import androidx.compose.ui.text.input.ImeAction
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun EnterTextField(
value: String,
onValueChange: (String) -> Unit,
onConfirmInput: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: @Composable (() -> Unit)? = null
) {
TextField(
label = label,
value = value,
onValueChange = onValueChange,
enabled = enabled,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { onConfirmInput() }),
modifier = modifier.onPreviewKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == Key.Enter) {
onConfirmInput()
true
} else false
}
)
}

View file

@ -0,0 +1,64 @@
/*
* TimeLimit Copyright <C> 2019 - 2023 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.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
@Composable
fun SwitchRow(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
Row (
modifier
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { onCheckedChange(!checked) },
enabled = enabled,
role = Role.Switch,
onClickLabel = label
),
verticalAlignment = Alignment.CenterVertically
) {
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled
)
Spacer(Modifier.width(8.dp))
Text(label)
}
}

View file

@ -139,6 +139,7 @@
<string name="authenticate_by_mail_other_title">Mit einer E-Mail-Adresse anmelden</string>
<string name="authenticate_by_mail_other_text">TimeLimit wird Ihnen eine Nachricht mit einem Code senden, den Sie zum Authentifizieren hier eingeben.</string>
<string name="authenticate_by_mail_hardcoded_text">TimeLimit wird Ihnen eine Nachricht mit einem Code an %s senden, den Sie zum Authentifizieren hier eingeben.</string>
<string name="authenticate_by_mail_message_sent">Es wurde ein Code an %s gesendet.</string>
<string name="authenticate_by_mail_hint_mail">E-Mail-Adresse</string>
<string name="authenticate_by_mail_hint_code">empfangener Code</string>
@ -574,6 +575,7 @@
<string name="error_general">Der Vorgang ist fehlgeschlagen</string>
<string name="error_network">Es trat ein Fehler bei der Übertragung auf</string>
<string name="error_server_rejected">Der Server hat die Anfrage abgelehnt</string>
<string name="error_server_client_deprecated">Diese Aktion erfordert eine neuere App-Version</string>
<string name="homescreen_selection_title">Startbildschirmauswahl</string>
<string name="homescreen_selection_intro">TimeLimit ist kein vollständiger Startbildschirm, aber Sie können auswählen, was angezeigt werden soll</string>
@ -1079,6 +1081,8 @@
<string name="manage_parent_remove_user_status_last_linked">
Sie sind im vernetzen Modus und dieser Benutzer ist der letzte, mit dem eine E-Mail-Adresse verknüpft wurde.
Sie müssen einen anderen Benutzer verknüpfen, bevor Sie diesen löschen können.
Sie können in der Übersicht die gesamte Registrierung löschen, aber das
umfasst auch alle eingeschränkten Benutzer und verknüpften Geräte.
</string>
<string name="manage_parent_remove_user_status_not_authenticated">
Melden Sie sich mit einem anderen Benutzer an, um %1$s zu löschen.
@ -1742,4 +1746,16 @@
<string name="u2f_login_error_unknown">unbekannter Schlüssel</string>
<string name="u2f_login_error_invalid">ungültiger Schlüssel</string>
<string name="account_deletion_title">Registrierung löschen</string>
<string name="account_deletion_text_no_account">Sie verwenden keine Vernetzung, sodass es keine Registrierung gibt, die gelöscht werden könnte</string>
<string name="account_deletion_text_done">Ihre Anfrage wurde gesendet</string>
<string name="account_deletion_confirmation_text">Hiermit können Sie Ihre Registrierung löschen lassen.
Damit wird TimeLimit auf allen verknüpften Geräten zurückgesetzt.
Wenn Sie das nicht tun, erfolgt eine automatische Löschung, wenn Sie TimeLimit nicht mehr verwenden.
Einige Daten können nicht auf Anfrage gelöscht werden.
Die Details finden Sie in der Datenschutzerklärung.
</string>
<string name="account_deletion_confirmation_premium_toggle">Ich verzichte ersatzlos auf eine evtl. vorhandene Restlaufzeit meiner Vollversion</string>
<string name="account_deletion_confirmation_button">Löschung anfordern</string>
</resources>

View file

@ -185,6 +185,7 @@
<string name="authenticate_by_mail_other_title">Sign in with a mail address</string>
<string name="authenticate_by_mail_other_text">TimeLimit will send you a message with a code which you enter here to authenticate.</string>
<string name="authenticate_by_mail_hardcoded_text">TimeLimit will send you a message to %s with a code which you enter here to authenticate.</string>
<string name="authenticate_by_mail_message_sent">A code was sent to %s.</string>
<string name="authenticate_by_mail_hint_mail">Mail address</string>
<string name="authenticate_by_mail_hint_code">Received code</string>
@ -628,6 +629,7 @@
<string name="error_general">The operation failed</string>
<string name="error_network">Something went wrong during the transmission</string>
<string name="error_server_rejected">The server rejected the request</string>
<string name="error_server_client_deprecated">This action requires a newer App version</string>
<string name="homescreen_selection_title">Homescreen selection</string>
<string name="homescreen_selection_intro">TimeLimit itself is no complete homescreen, so you can chose what should be shown</string>
@ -1130,6 +1132,8 @@
<string name="manage_parent_remove_user_status_last_linked">
You are using the connected mode and this user is the last one with a linked mail address.
You must link an other user before you can delete this one.
Alternatively, you can delete your registration from the overview screen but
this deletes all child users and linked devices too.
</string>
<string name="manage_parent_remove_user_status_not_authenticated">
Sign in with an other user to delete %1$s.
@ -1794,4 +1798,17 @@
<string name="u2f_login_error_unknown">unknown key</string>
<string name="u2f_login_error_invalid">invalid key</string>
<string name="account_deletion_title">Delete Registration</string>
<string name="account_deletion_text_no_account">You are not using the connect mode so there is no account to delete</string>
<string name="account_deletion_text_done">You request was sent</string>
<string name="account_deletion_confirmation_text">Using this, you can
request the deletion of your registration.
This will reset TimeLimit on all linked devices.
If you do not this, your data is deleted automatically if you stop using TimeLimit.
Some data cannot be deleted upon request.
You can find the details in the privacy policy.
</string>
<string name="account_deletion_confirmation_premium_toggle">I accept that my premium version (if it exists) will be deleted without replacement</string>
<string name="account_deletion_confirmation_button">Request Deletion</string>
</resources>