mirror of
https://codeberg.org/timelimit/timelimit-android.git
synced 2025-10-03 09:49:25 +02:00
Add account deletion option
This commit is contained in:
parent
32652e2890
commit
0829e4e440
24 changed files with 1025 additions and 26 deletions
|
@ -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
|
* 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
|
* 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 removeDevice(deviceAuthToken: String, parentUserId: String, parentPasswordSecondHash: String, deviceId: String)
|
||||||
suspend fun isDeviceRemoved(deviceAuthToken: String): Boolean
|
suspend fun isDeviceRemoved(deviceAuthToken: String): Boolean
|
||||||
suspend fun createIdentityToken(deviceAuthToken: String, parentUserId: String, parentPasswordSecondHash: String): String
|
suspend fun createIdentityToken(deviceAuthToken: String, parentUserId: String, parentPasswordSecondHash: String): String
|
||||||
|
suspend fun requestAccountDeletion(deviceAuthToken: String, mailAuthTokens: List<String>)
|
||||||
}
|
}
|
||||||
|
|
||||||
class MailServerBlacklistedException: RuntimeException()
|
class MailServerBlacklistedException: RuntimeException()
|
||||||
|
|
|
@ -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
|
* 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
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
@ -106,4 +106,11 @@ class DummyServerApi: ServerApi {
|
||||||
): String {
|
): String {
|
||||||
throw IOException()
|
throw IOException()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun requestAccountDeletion(
|
||||||
|
deviceAuthToken: String,
|
||||||
|
mailAuthTokens: List<String>
|
||||||
|
) {
|
||||||
|
throw IOException()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,8 +42,8 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
|
||||||
private const val LOG_TAG = "HttpServerApi"
|
private const val LOG_TAG = "HttpServerApi"
|
||||||
|
|
||||||
private const val DEVICE_AUTH_TOKEN = "deviceAuthToken"
|
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_TOKEN = "mailAuthToken"
|
||||||
|
private const val MAIL_AUTH_TOKENS = "mailAuthTokens"
|
||||||
private const val REGISTER_TOKEN = "registerToken"
|
private const val REGISTER_TOKEN = "registerToken"
|
||||||
private const val MILLISECONDS = "ms"
|
private const val MILLISECONDS = "ms"
|
||||||
private const val STATUS = "status"
|
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(
|
private suspend fun postJsonRequest(
|
||||||
path: String,
|
path: String,
|
||||||
requestBody: (writer: JsonWriter) -> Unit
|
requestBody: (writer: JsonWriter) -> Unit
|
||||||
|
|
|
@ -18,6 +18,7 @@ package io.timelimit.android.ui
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.fragment.app.FragmentManager
|
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.diagnose.deviceowner.DeviceOwnerScreen
|
||||||
import io.timelimit.android.ui.manage.device.manage.permission.ManageDevicePermissionScreen
|
import io.timelimit.android.ui.manage.device.manage.permission.ManageDevicePermissionScreen
|
||||||
import io.timelimit.android.ui.manage.device.manage.user.ManageDeviceUserScreen
|
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.SetupConnectModePrivacyScreen -> SetupConnectedModePrivacyScreen(screen.customServerDomain, screen.accept, modifier)
|
||||||
is Screen.SetupSelectConnectedModeScreen -> SelectConnectedModeScreen(mailLogin = screen.mailLogin, codeLogin = screen.codeLogin, modifier = 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.SetupSelectModeScreen -> SelectModeScreen(selectLocal = screen.selectLocal, selectConnected = screen.selectConnected, selectUninstall = screen.selectUninstall, modifier = modifier)
|
||||||
|
is Screen.DeleteRegistration -> DeleteRegistrationScreen(screen.content, modifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -32,6 +32,7 @@ import io.timelimit.android.livedata.liveDataFromFunction
|
||||||
import io.timelimit.android.livedata.liveDataFromNullableValue
|
import io.timelimit.android.livedata.liveDataFromNullableValue
|
||||||
import io.timelimit.android.logic.DefaultAppLogic
|
import io.timelimit.android.logic.DefaultAppLogic
|
||||||
import io.timelimit.android.sync.websocket.networkstatus.NetworkStatus
|
import io.timelimit.android.sync.websocket.networkstatus.NetworkStatus
|
||||||
|
import io.timelimit.android.ui.diagnose.exception.DiagnoseExceptionDialogFragment
|
||||||
import io.timelimit.android.ui.main.FragmentWithCustomTitle
|
import io.timelimit.android.ui.main.FragmentWithCustomTitle
|
||||||
|
|
||||||
class DiagnoseConnectionFragment : Fragment(), FragmentWithCustomTitle {
|
class DiagnoseConnectionFragment : Fragment(), FragmentWithCustomTitle {
|
||||||
|
|
|
@ -27,6 +27,7 @@ import io.timelimit.android.databinding.FragmentDiagnoseMainBinding
|
||||||
import io.timelimit.android.livedata.liveDataFromNonNullValue
|
import io.timelimit.android.livedata.liveDataFromNonNullValue
|
||||||
import io.timelimit.android.livedata.liveDataFromNullableValue
|
import io.timelimit.android.livedata.liveDataFromNullableValue
|
||||||
import io.timelimit.android.logic.DefaultAppLogic
|
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.ActivityViewModelHolder
|
||||||
import io.timelimit.android.ui.main.AuthenticationFab
|
import io.timelimit.android.ui.main.AuthenticationFab
|
||||||
import io.timelimit.android.ui.main.FragmentWithCustomTitle
|
import io.timelimit.android.ui.main.FragmentWithCustomTitle
|
||||||
|
|
|
@ -34,6 +34,7 @@ import io.timelimit.android.databinding.DiagnoseSyncFragmentBinding
|
||||||
import io.timelimit.android.livedata.liveDataFromNullableValue
|
import io.timelimit.android.livedata.liveDataFromNullableValue
|
||||||
import io.timelimit.android.logic.DefaultAppLogic
|
import io.timelimit.android.logic.DefaultAppLogic
|
||||||
import io.timelimit.android.sync.actions.apply.UploadActionsUtil
|
import io.timelimit.android.sync.actions.apply.UploadActionsUtil
|
||||||
|
import io.timelimit.android.ui.diagnose.exception.DiagnoseExceptionDialogFragment
|
||||||
import io.timelimit.android.ui.main.FragmentWithCustomTitle
|
import io.timelimit.android.ui.main.FragmentWithCustomTitle
|
||||||
|
|
||||||
class DiagnoseSyncFragment : Fragment(), FragmentWithCustomTitle {
|
class DiagnoseSyncFragment : Fragment(), FragmentWithCustomTitle {
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
* 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
|
* 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
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.app.Dialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
@ -23,8 +23,6 @@ import androidx.fragment.app.FragmentManager
|
||||||
import io.timelimit.android.R
|
import io.timelimit.android.R
|
||||||
import io.timelimit.android.extensions.showSafe
|
import io.timelimit.android.extensions.showSafe
|
||||||
import io.timelimit.android.util.Clipboard
|
import io.timelimit.android.util.Clipboard
|
||||||
import java.io.PrintWriter
|
|
||||||
import java.io.StringWriter
|
|
||||||
|
|
||||||
class DiagnoseExceptionDialogFragment: DialogFragment() {
|
class DiagnoseExceptionDialogFragment: DialogFragment() {
|
||||||
companion object {
|
companion object {
|
||||||
|
@ -36,19 +34,10 @@ class DiagnoseExceptionDialogFragment: DialogFragment() {
|
||||||
putSerializable(EXCEPTION, exception)
|
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 {
|
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)
|
return AlertDialog.Builder(requireContext(), theme)
|
||||||
.setMessage(message)
|
.setMessage(message)
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
|
@ -23,6 +23,7 @@ import io.timelimit.android.R
|
||||||
import io.timelimit.android.data.model.UserType
|
import io.timelimit.android.data.model.UserType
|
||||||
import io.timelimit.android.logic.DefaultAppLogic
|
import io.timelimit.android.logic.DefaultAppLogic
|
||||||
import io.timelimit.android.ui.main.ActivityViewModel
|
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.diagnose.DeviceOwnerHandling
|
||||||
import io.timelimit.android.ui.model.flow.Case
|
import io.timelimit.android.ui.model.flow.Case
|
||||||
import io.timelimit.android.ui.model.flow.splitConflated
|
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.ManageDevice> { state -> ManageDeviceHandling.processState(logic, activityCommandInternal, authenticationModelApi, state, updateMethod(::updateState)) },
|
||||||
Case.simple<_, _, State.DiagnoseScreen.DeviceOwner> { DeviceOwnerHandling.processState(logic, scope, authenticationModelApi, state) },
|
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.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 ->
|
Case.simple<_, _, FragmentState> { state ->
|
||||||
state.transform {
|
state.transform {
|
||||||
val containerId = it.containerId ?: run {
|
val containerId = it.containerId ?: run {
|
||||||
|
|
|
@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.Key
|
||||||
import androidx.compose.material.icons.outlined.Info
|
import androidx.compose.material.icons.outlined.Info
|
||||||
import io.timelimit.android.R
|
import io.timelimit.android.R
|
||||||
import io.timelimit.android.ui.manage.device.manage.permission.PermissionScreenContent
|
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.diagnose.DeviceOwnerHandling
|
||||||
import io.timelimit.android.ui.model.main.OverviewHandling
|
import io.timelimit.android.ui.model.main.OverviewHandling
|
||||||
import io.timelimit.android.ui.model.managedevice.ManageDeviceUser
|
import io.timelimit.android.ui.model.managedevice.ManageDeviceUser
|
||||||
|
@ -49,10 +50,16 @@ sealed class Screen(
|
||||||
R.string.main_tab_about,
|
R.string.main_tab_about,
|
||||||
UpdateStateCommand.Overview.LaunchAbout
|
UpdateStateCommand.Overview.LaunchAbout
|
||||||
)),
|
)),
|
||||||
listOf(Menu.Dropdown(
|
listOf(
|
||||||
|
Menu.Dropdown(
|
||||||
R.string.main_tab_uninstall,
|
R.string.main_tab_uninstall,
|
||||||
UpdateStateCommand.Overview.Uninstall
|
UpdateStateCommand.Overview.Uninstall
|
||||||
))
|
),
|
||||||
|
Menu.Dropdown(
|
||||||
|
R.string.account_deletion_title,
|
||||||
|
UpdateStateCommand.Overview.DeleteAccount
|
||||||
|
)
|
||||||
|
)
|
||||||
), ScreenWithAuthenticationFab, ScreenWithSnackbar
|
), ScreenWithAuthenticationFab, ScreenWithSnackbar
|
||||||
|
|
||||||
class ManageChildScreen(
|
class ManageChildScreen(
|
||||||
|
@ -252,6 +259,14 @@ sealed class Screen(
|
||||||
val mailLogin: () -> Unit,
|
val mailLogin: () -> Unit,
|
||||||
val codeLogin: () -> Unit
|
val codeLogin: () -> Unit
|
||||||
): Screen(state)
|
): 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
|
interface ScreenWithAuthenticationFab
|
||||||
|
|
|
@ -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.password.restore.RestoreParentPasswordFragmentArgs
|
||||||
import io.timelimit.android.ui.manage.parent.u2fkey.ManageParentU2FKeyFragment
|
import io.timelimit.android.ui.manage.parent.u2fkey.ManageParentU2FKeyFragment
|
||||||
import io.timelimit.android.ui.manage.parent.u2fkey.ManageParentU2FKeyFragmentArgs
|
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.diagnose.DeviceOwnerHandling
|
||||||
import io.timelimit.android.ui.model.main.OverviewHandling
|
import io.timelimit.android.ui.model.main.OverviewHandling
|
||||||
import io.timelimit.android.ui.overview.uninstall.UninstallFragment
|
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)
|
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)
|
class Uninstall(previous: Overview): FragmentStateLegacy(previous = previous, fragmentClass = UninstallFragment::class.java)
|
||||||
object DiagnoseScreen {
|
object DiagnoseScreen {
|
||||||
class Main(previous: About): FragmentStateLegacy(previous, DiagnoseMainFragment::class.java)
|
class Main(previous: About): FragmentStateLegacy(previous, DiagnoseMainFragment::class.java)
|
||||||
|
|
|
@ -83,6 +83,12 @@ sealed class UpdateStateCommand {
|
||||||
else null
|
else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object DeleteAccount: UpdateStateCommand() {
|
||||||
|
override fun transform(state: State): State? =
|
||||||
|
if (state is State.Overview) State.DeleteAccount(state)
|
||||||
|
else null
|
||||||
|
}
|
||||||
|
|
||||||
object ShowAllUsers: UpdateStateCommand() {
|
object ShowAllUsers: UpdateStateCommand() {
|
||||||
override fun transform(state: State): State? =
|
override fun transform(state: State): State? =
|
||||||
if (state is State.Overview) state.copy(
|
if (state is State.Overview) state.copy(
|
||||||
|
|
|
@ -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) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -27,7 +27,7 @@ import io.timelimit.android.databinding.FragmentPurchaseBinding
|
||||||
import io.timelimit.android.livedata.liveDataFromNullableValue
|
import io.timelimit.android.livedata.liveDataFromNullableValue
|
||||||
import io.timelimit.android.livedata.mergeLiveData
|
import io.timelimit.android.livedata.mergeLiveData
|
||||||
import io.timelimit.android.ui.MainActivity
|
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.ActivityViewModelHolder
|
||||||
import io.timelimit.android.ui.main.FragmentWithCustomTitle
|
import io.timelimit.android.ui.main.FragmentWithCustomTitle
|
||||||
import io.timelimit.android.ui.main.getActivityViewModel
|
import io.timelimit.android.ui.main.getActivityViewModel
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
64
app/src/main/java/io/timelimit/android/ui/view/SwitchRow.kt
Normal file
64
app/src/main/java/io/timelimit/android/ui/view/SwitchRow.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -139,6 +139,7 @@
|
||||||
|
|
||||||
<string name="authenticate_by_mail_other_title">Mit einer E-Mail-Adresse anmelden</string>
|
<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_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_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_mail">E-Mail-Adresse</string>
|
||||||
<string name="authenticate_by_mail_hint_code">empfangener Code</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_general">Der Vorgang ist fehlgeschlagen</string>
|
||||||
<string name="error_network">Es trat ein Fehler bei der Übertragung auf</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_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_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>
|
<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">
|
<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 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 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>
|
||||||
<string name="manage_parent_remove_user_status_not_authenticated">
|
<string name="manage_parent_remove_user_status_not_authenticated">
|
||||||
Melden Sie sich mit einem anderen Benutzer an, um %1$s zu löschen.
|
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_unknown">unbekannter Schlüssel</string>
|
||||||
<string name="u2f_login_error_invalid">ungültiger 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>
|
</resources>
|
||||||
|
|
|
@ -185,6 +185,7 @@
|
||||||
|
|
||||||
<string name="authenticate_by_mail_other_title">Sign in with a mail address</string>
|
<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_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_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_mail">Mail address</string>
|
||||||
<string name="authenticate_by_mail_hint_code">Received code</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_general">The operation failed</string>
|
||||||
<string name="error_network">Something went wrong during the transmission</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_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_title">Homescreen selection</string>
|
||||||
<string name="homescreen_selection_intro">TimeLimit itself is no complete homescreen, so you can chose what should be shown</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">
|
<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 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.
|
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>
|
||||||
<string name="manage_parent_remove_user_status_not_authenticated">
|
<string name="manage_parent_remove_user_status_not_authenticated">
|
||||||
Sign in with an other user to delete %1$s.
|
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_unknown">unknown key</string>
|
||||||
<string name="u2f_login_error_invalid">invalid 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>
|
</resources>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue