From 0829e4e4402f772994747e7eda4fa83a1f892e09 Mon Sep 17 00:00:00 2001 From: Jonas Lochmann Date: Mon, 3 Apr 2023 02:00:00 +0200 Subject: [PATCH] Add account deletion option --- .../timelimit/android/sync/network/api/Api.kt | 3 +- .../sync/network/api/DummyServerApi.kt | 9 +- .../android/sync/network/api/HttpServerApi.kt | 20 +- .../timelimit/android/ui/ScreenMultiplexer.kt | 2 + .../ui/account/DeleteRegistrationScreen.kt | 103 ++++++++ .../AuthenticateByMailScreen.kt | 108 ++++++++ .../ui/diagnose/DiagnoseConnectionFragment.kt | 1 + .../ui/diagnose/DiagnoseMainFragment.kt | 1 + .../ui/diagnose/DiagnoseSyncFragment.kt | 1 + .../exception/DiagnoseExceptionDialog.kt | 47 ++++ .../DiagnoseExceptionDialogFragment.kt | 25 +- .../ui/diagnose/exception/ExceptionUtil.kt | 30 +++ .../diagnose/exception/SimpleErrorDialog.kt | 37 +++ .../timelimit/android/ui/model/MainModel.kt | 2 + .../io/timelimit/android/ui/model/Screen.kt | 23 +- .../io/timelimit/android/ui/model/State.kt | 5 + .../android/ui/model/UpdateStateCommand.kt | 6 + .../ui/model/account/AccountDeletion.kt | 241 ++++++++++++++++++ .../mailauthentication/MailAuthentication.kt | 235 +++++++++++++++++ .../android/ui/payment/PurchaseFragment.kt | 2 +- .../android/ui/view/EnterTextField.kt | 53 ++++ .../io/timelimit/android/ui/view/SwitchRow.kt | 64 +++++ app/src/main/res/values-de/strings.xml | 16 ++ app/src/main/res/values/strings.xml | 17 ++ 24 files changed, 1025 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/io/timelimit/android/ui/account/DeleteRegistrationScreen.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/authentication/AuthenticateByMailScreen.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/diagnose/exception/DiagnoseExceptionDialog.kt rename app/src/main/java/io/timelimit/android/ui/diagnose/{ => exception}/DiagnoseExceptionDialogFragment.kt (67%) create mode 100644 app/src/main/java/io/timelimit/android/ui/diagnose/exception/ExceptionUtil.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/diagnose/exception/SimpleErrorDialog.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/model/account/AccountDeletion.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/model/mailauthentication/MailAuthentication.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/view/EnterTextField.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/view/SwitchRow.kt diff --git a/app/src/main/java/io/timelimit/android/sync/network/api/Api.kt b/app/src/main/java/io/timelimit/android/sync/network/api/Api.kt index 77d7cd4..920393d 100644 --- a/app/src/main/java/io/timelimit/android/sync/network/api/Api.kt +++ b/app/src/main/java/io/timelimit/android/sync/network/api/Api.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 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) } class MailServerBlacklistedException: RuntimeException() diff --git a/app/src/main/java/io/timelimit/android/sync/network/api/DummyServerApi.kt b/app/src/main/java/io/timelimit/android/sync/network/api/DummyServerApi.kt index 86dd9c9..bb27987 100644 --- a/app/src/main/java/io/timelimit/android/sync/network/api/DummyServerApi.kt +++ b/app/src/main/java/io/timelimit/android/sync/network/api/DummyServerApi.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 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 + ) { + throw IOException() + } } diff --git a/app/src/main/java/io/timelimit/android/sync/network/api/HttpServerApi.kt b/app/src/main/java/io/timelimit/android/sync/network/api/HttpServerApi.kt index 926f9ea..b1021b9 100644 --- a/app/src/main/java/io/timelimit/android/sync/network/api/HttpServerApi.kt +++ b/app/src/main/java/io/timelimit/android/sync/network/api/HttpServerApi.kt @@ -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 + ) { + 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 diff --git a/app/src/main/java/io/timelimit/android/ui/ScreenMultiplexer.kt b/app/src/main/java/io/timelimit/android/ui/ScreenMultiplexer.kt index 0d64419..90266ac 100644 --- a/app/src/main/java/io/timelimit/android/ui/ScreenMultiplexer.kt +++ b/app/src/main/java/io/timelimit/android/ui/ScreenMultiplexer.kt @@ -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) } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/account/DeleteRegistrationScreen.kt b/app/src/main/java/io/timelimit/android/ui/account/DeleteRegistrationScreen.kt new file mode 100644 index 0000000..4756f45 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/account/DeleteRegistrationScreen.kt @@ -0,0 +1,103 @@ +/* + * TimeLimit Copyright 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 . + */ +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) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/authentication/AuthenticateByMailScreen.kt b/app/src/main/java/io/timelimit/android/ui/authentication/AuthenticateByMailScreen.kt new file mode 100644 index 0000000..f3bc2b9 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/authentication/AuthenticateByMailScreen.kt @@ -0,0 +1,108 @@ +/* + * TimeLimit Copyright 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 . + */ +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) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseConnectionFragment.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseConnectionFragment.kt index 56579d6..1227bcb 100644 --- a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseConnectionFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseConnectionFragment.kt @@ -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 { diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseMainFragment.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseMainFragment.kt index 628a565..0912e03 100644 --- a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseMainFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseMainFragment.kt @@ -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 diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseSyncFragment.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseSyncFragment.kt index 23bd7b2..876672d 100644 --- a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseSyncFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseSyncFragment.kt @@ -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 { diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/exception/DiagnoseExceptionDialog.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/exception/DiagnoseExceptionDialog.kt new file mode 100644 index 0000000..07b1ffe --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/exception/DiagnoseExceptionDialog.kt @@ -0,0 +1,47 @@ +/* + * TimeLimit Copyright 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 . + */ +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)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseExceptionDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/exception/DiagnoseExceptionDialogFragment.kt similarity index 67% rename from app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseExceptionDialogFragment.kt rename to app/src/main/java/io/timelimit/android/ui/diagnose/exception/DiagnoseExceptionDialogFragment.kt index f500521..8fbbe26 100644 --- a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseExceptionDialogFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/exception/DiagnoseExceptionDialogFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 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 . */ -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,25 +34,16 @@ 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) - .setNeutralButton(R.string.diagnose_sync_copy_to_clipboard) { _, _ -> Clipboard.setAndToast(requireContext(), message) } - .setPositiveButton(R.string.generic_ok, null) - .create() + .setMessage(message) + .setNeutralButton(R.string.diagnose_sync_copy_to_clipboard) { _, _ -> Clipboard.setAndToast(requireContext(), message) } + .setPositiveButton(R.string.generic_ok, null) + .create() } fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/exception/ExceptionUtil.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/exception/ExceptionUtil.kt new file mode 100644 index 0000000..1c59581 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/exception/ExceptionUtil.kt @@ -0,0 +1,30 @@ +/* + * TimeLimit Copyright 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 . + */ +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() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/exception/SimpleErrorDialog.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/exception/SimpleErrorDialog.kt new file mode 100644 index 0000000..34475dd --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/exception/SimpleErrorDialog.kt @@ -0,0 +1,37 @@ +/* + * TimeLimit Copyright 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 . + */ +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)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/MainModel.kt b/app/src/main/java/io/timelimit/android/ui/model/MainModel.kt index e7266cf..0040806 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/MainModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/MainModel.kt @@ -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 { diff --git a/app/src/main/java/io/timelimit/android/ui/model/Screen.kt b/app/src/main/java/io/timelimit/android/ui/model/Screen.kt index e7fcef5..2a971c9 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/Screen.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/Screen.kt @@ -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( - R.string.main_tab_uninstall, - UpdateStateCommand.Overview.Uninstall - )) + 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 diff --git a/app/src/main/java/io/timelimit/android/ui/model/State.kt b/app/src/main/java/io/timelimit/android/ui/model/State.kt index 682c6bf..e19784e 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/State.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/State.kt @@ -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) diff --git a/app/src/main/java/io/timelimit/android/ui/model/UpdateStateCommand.kt b/app/src/main/java/io/timelimit/android/ui/model/UpdateStateCommand.kt index 337451e..260bce8 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/UpdateStateCommand.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/UpdateStateCommand.kt @@ -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( diff --git a/app/src/main/java/io/timelimit/android/ui/model/account/AccountDeletion.kt b/app/src/main/java/io/timelimit/android/ui/model/account/AccountDeletion.kt new file mode 100644 index 0000000..56a7cb3 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/model/account/AccountDeletion.kt @@ -0,0 +1,241 @@ +/* + * TimeLimit Copyright 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 . + */ +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 = emptyMap(), + val didConfirmPremiumLoss: Boolean = false, + val currentMailAuthentication: Map = 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, + updateState: ((State.DeleteAccount) -> State) -> Unit + ): Flow { + 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, + updateState: ((MyState) -> MyState) -> Unit + ): Flow = 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, + updateState: ((MyState.Preparing) -> MyState) -> Unit + ): Flow { + 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) } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/mailauthentication/MailAuthentication.kt b/app/src/main/java/io/timelimit/android/ui/model/mailauthentication/MailAuthentication.kt new file mode 100644 index 0000000..9f7045f --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/model/mailauthentication/MailAuthentication.kt @@ -0,0 +1,235 @@ +/* + * TimeLimit Copyright 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 . + */ +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, + updateState: ((State) -> State) -> Unit, + processAuthToken: suspend (String) -> Unit + ): Flow { + 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 + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/payment/PurchaseFragment.kt b/app/src/main/java/io/timelimit/android/ui/payment/PurchaseFragment.kt index 1e4de11..3e2a9c7 100644 --- a/app/src/main/java/io/timelimit/android/ui/payment/PurchaseFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/payment/PurchaseFragment.kt @@ -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 diff --git a/app/src/main/java/io/timelimit/android/ui/view/EnterTextField.kt b/app/src/main/java/io/timelimit/android/ui/view/EnterTextField.kt new file mode 100644 index 0000000..c57ad5f --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/view/EnterTextField.kt @@ -0,0 +1,53 @@ +/* + * TimeLimit Copyright 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 . + */ +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 + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/view/SwitchRow.kt b/app/src/main/java/io/timelimit/android/ui/view/SwitchRow.kt new file mode 100644 index 0000000..4edfd1d --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/view/SwitchRow.kt @@ -0,0 +1,64 @@ +/* + * TimeLimit Copyright 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 . + */ +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) + } +} \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index bfe4576..50c63b9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -139,6 +139,7 @@ Mit einer E-Mail-Adresse anmelden TimeLimit wird Ihnen eine Nachricht mit einem Code senden, den Sie zum Authentifizieren hier eingeben. + TimeLimit wird Ihnen eine Nachricht mit einem Code an %s senden, den Sie zum Authentifizieren hier eingeben. Es wurde ein Code an %s gesendet. E-Mail-Adresse empfangener Code @@ -574,6 +575,7 @@ Der Vorgang ist fehlgeschlagen Es trat ein Fehler bei der Übertragung auf Der Server hat die Anfrage abgelehnt + Diese Aktion erfordert eine neuere App-Version Startbildschirmauswahl TimeLimit ist kein vollständiger Startbildschirm, aber Sie können auswählen, was angezeigt werden soll @@ -1079,6 +1081,8 @@ 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. Melden Sie sich mit einem anderen Benutzer an, um %1$s zu löschen. @@ -1742,4 +1746,16 @@ unbekannter Schlüssel ungültiger Schlüssel + + Registrierung löschen + Sie verwenden keine Vernetzung, sodass es keine Registrierung gibt, die gelöscht werden könnte + Ihre Anfrage wurde gesendet + 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. + + Ich verzichte ersatzlos auf eine evtl. vorhandene Restlaufzeit meiner Vollversion + Löschung anfordern diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c2cdde4..c01e71f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -185,6 +185,7 @@ Sign in with a mail address TimeLimit will send you a message with a code which you enter here to authenticate. + TimeLimit will send you a message to %s with a code which you enter here to authenticate. A code was sent to %s. Mail address Received code @@ -628,6 +629,7 @@ The operation failed Something went wrong during the transmission The server rejected the request + This action requires a newer App version Homescreen selection TimeLimit itself is no complete homescreen, so you can chose what should be shown @@ -1130,6 +1132,8 @@ 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. Sign in with an other user to delete %1$s. @@ -1794,4 +1798,17 @@ unknown key invalid key + + Delete Registration + You are not using the connect mode so there is no account to delete + You request was sent + 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. + + I accept that my premium version (if it exists) will be deleted without replacement + Request Deletion