Add new parent setup UI

This commit is contained in:
Jonas Lochmann 2023-05-01 02:00:00 +02:00
parent be894e876f
commit 7a3418ae1d
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
24 changed files with 1108 additions and 630 deletions

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,13 +16,14 @@
package io.timelimit.android.sync.network
import android.util.JsonReader
import java.io.Serializable
data class StatusOfMailAddressResponse(
val mail: String,
val status: StatusOfMailAddress,
val canCreateFamily: Boolean,
val alwaysPro: Boolean
) {
): Serializable {
companion object {
fun parse(reader: JsonReader): StatusOfMailAddressResponse {
var mail: String? = null

View file

@ -15,6 +15,7 @@
*/
package io.timelimit.android.ui
import android.Manifest
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
@ -26,6 +27,7 @@ import android.provider.Settings
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent
@ -123,6 +125,10 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder, U2fManager.De
override var ignoreStop: Boolean = false
override val showPasswordRecovery: Boolean = true
private val requestNotifyPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) mainModel.reportPermissionsChanged()
}
@OptIn(ExperimentalAnimationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -162,6 +168,7 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder, U2fManager.De
} catch (ex: Exception) {
message.errorHandler()
}
ActivityCommand.RequestNotifyPermission -> requestNotifyPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}

View file

@ -19,6 +19,7 @@ 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.authentication.AuthenticateByMailScreen
import io.timelimit.android.ui.diagnose.deviceowner.DeviceOwnerScreen
import io.timelimit.android.ui.manage.category.blocked_times.BlockedTimesScreen
import io.timelimit.android.ui.manage.child.usagehistory.UsageHistoryScreen
@ -28,6 +29,11 @@ import io.timelimit.android.ui.model.Screen
import io.timelimit.android.ui.overview.overview.OverviewScreen
import io.timelimit.android.ui.setup.selectmode.SelectConnectedModeScreen
import io.timelimit.android.ui.setup.SetupDevicePermissionsScreen
import io.timelimit.android.ui.setup.parent.ConfirmNewParentAccount
import io.timelimit.android.ui.setup.parent.ParentBaseConfiguration
import io.timelimit.android.ui.setup.parent.ParentSetupConsent
import io.timelimit.android.ui.setup.parent.SignInWrongMailAddress
import io.timelimit.android.ui.setup.parent.SignupBlockedScreen
import io.timelimit.android.ui.setup.privacy.SetupConnectedModePrivacyScreen
import io.timelimit.android.ui.setup.selectmode.SelectModeScreen
@ -52,5 +58,11 @@ fun ScreenMultiplexer(
is Screen.DeleteRegistration -> DeleteRegistrationScreen(screen.content, modifier)
is Screen.ManageBlockedTimes -> BlockedTimesScreen(screen.content, screen.intro, modifier)
is Screen.ChildUsageHistory -> UsageHistoryScreen(screen.content, modifier)
is Screen.SetupParentMailAuthentication -> AuthenticateByMailScreen(screen.content, modifier)
is Screen.SignupBlocked -> SignupBlockedScreen(modifier)
is Screen.SignInWrongMailAddress -> SignInWrongMailAddress(modifier)
is Screen.ConfirmNewParentAccount -> ConfirmNewParentAccount(confirm = screen.confirm, reject = screen.reject, modifier = modifier)
is Screen.ParentBaseConfiguration -> ParentBaseConfiguration(content = screen.content, modifier = modifier)
is Screen.ParentSetupConsent -> ParentSetupConsent(content = screen.content, errorDialog = screen.errorDialog, modifier = modifier)
}
}

View file

@ -24,4 +24,5 @@ sealed class ActivityCommand {
object ShowMissingPremiumDialog: ActivityCommand()
class LaunchSystemSettings(val permission: SystemPermission): ActivityCommand()
class TriggerUninstall(val packageName: String, val errorHandler: () -> Unit): ActivityCommand()
object RequestNotifyPermission: ActivityCommand()
}

View file

@ -63,6 +63,7 @@ class MainModel(application: Application): AndroidViewModel(application) {
private val activityCommandInternal = Channel<ActivityCommand>()
private val authenticationScreenClosed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val permissionsChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val authenticationModelApi = object: AuthenticationModelApi {
override val authenticatedParentOnly: Flow<AuthenticationModelApi.Parent?> =
@ -114,7 +115,7 @@ class MainModel(application: Application): AndroidViewModel(application) {
Case.simple<_, _, State.ManageChild> { state -> ManageChildHandling.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.Setup> { state -> SetupHandling.handle(logic, activityCommandInternal, state, updateMethod(::updateState)) },
Case.simple<_, _, State.Setup> { state -> SetupHandling.handle(logic, activityCommandInternal, permissionsChanged, state, updateMethod(::updateState)) },
Case.simple<_, _, State.DeleteAccount> { AccountDeletion.handle(logic, scope, share(it), updateMethod(::updateState)) },
Case.simple<_, _, FragmentState> { state ->
state.transform {
@ -141,5 +142,9 @@ class MainModel(application: Application): AndroidViewModel(application) {
authenticationScreenClosed.tryEmit(Unit)
}
fun reportPermissionsChanged() {
permissionsChanged.tryEmit(Unit)
}
private fun updateState(method: (State) -> State): Unit = state.update(method)
}

View file

@ -24,10 +24,12 @@ import io.timelimit.android.ui.manage.device.manage.permission.PermissionScreenC
import io.timelimit.android.ui.model.account.AccountDeletion
import io.timelimit.android.ui.model.diagnose.DeviceOwnerHandling
import io.timelimit.android.ui.model.intro.IntroHandling
import io.timelimit.android.ui.model.mailauthentication.MailAuthentication
import io.timelimit.android.ui.model.main.OverviewHandling
import io.timelimit.android.ui.model.managechild.ManageCategoryBlockedTimes
import io.timelimit.android.ui.model.managechild.ManageChildUsageHistory
import io.timelimit.android.ui.model.managedevice.ManageDeviceUser
import io.timelimit.android.ui.model.setup.SetupParentHandling
sealed class Screen(
val state: State,
@ -270,6 +272,37 @@ sealed class Screen(
): Screen(state), ScreenWithSnackbar, ScreenWithTitle {
override val title: Title = Title.StringResource(R.string.account_deletion_title)
}
class SetupParentMailAuthentication(
state: State.Setup.ParentMailAuthentication,
val content: MailAuthentication.Screen,
override val snackbarHostState: SnackbarHostState
): Screen(state), ScreenWithSnackbar
class SignupBlocked(state: State.Setup.SignUpBlocked): Screen(state)
class SignInWrongMailAddress(state: State.Setup.SignInWrongMailAddress): Screen(state)
class ConfirmNewParentAccount(
state: State.Setup.ConfirmNewParentAccount,
val reject: () -> Unit,
val confirm: () -> Unit
): Screen(state)
class ParentBaseConfiguration(
state: State.Setup.ParentBaseConfiguration,
val content: SetupParentHandling.ParentBaseConfiguration
): Screen(state), ScreenWithTitle {
override val title: Title get() =
if (content.newUserDetails != null) Title.StringResource(R.string.setup_parent_mode_create_family)
else Title.StringResource(R.string.setup_parent_mode_add_device)
}
class ParentSetupConsent(
state: State.Setup.ParentConsent,
val content: SetupParentHandling.ParentSetupConsent,
override val snackbarHostState: SnackbarHostState,
val errorDialog: Pair<String, () -> Unit>?
): Screen(state), ScreenWithSnackbar
}
interface ScreenWithAuthenticationFab

View file

@ -22,6 +22,8 @@ import androidx.compose.material.icons.filled.Phone
import androidx.fragment.app.Fragment
import io.timelimit.android.R
import io.timelimit.android.integration.platform.SystemPermission
import io.timelimit.android.sync.network.StatusOfMailAddress
import io.timelimit.android.sync.network.StatusOfMailAddressResponse
import io.timelimit.android.ui.contacts.ContactsFragment
import io.timelimit.android.ui.diagnose.*
import io.timelimit.android.ui.diagnose.exitreason.DiagnoseExitReasonFragment
@ -48,9 +50,11 @@ 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.mailauthentication.MailAuthentication
import io.timelimit.android.ui.model.main.OverviewHandling
import io.timelimit.android.ui.model.managechild.ManageCategoryBlockedTimes
import io.timelimit.android.ui.model.managechild.ManageChildUsageHistory
import io.timelimit.android.ui.model.setup.SetupParentHandling
import io.timelimit.android.ui.overview.uninstall.UninstallFragment
import io.timelimit.android.ui.parentmode.ParentModeFragment
import io.timelimit.android.ui.payment.PurchaseFragment
@ -58,8 +62,8 @@ import io.timelimit.android.ui.payment.StayAwesomeFragment
import io.timelimit.android.ui.setup.*
import io.timelimit.android.ui.setup.child.SetupRemoteChildFragment
import io.timelimit.android.ui.setup.device.SetupDeviceFragment
import io.timelimit.android.ui.setup.parent.SetupParentModeFragment
import io.timelimit.android.ui.user.create.AddUserFragment
import io.timelimit.android.ui.view.NotifyPermissionCard
import java.io.Serializable
sealed class State (val previous: State?): Serializable {
@ -270,7 +274,42 @@ sealed class State (val previous: State?): Serializable {
class ConnectedPrivacy(previousSelectMode: SelectMode): Setup(previousSelectMode)
class SelectConnectedMode(previousConnectedPrivacy: ConnectedPrivacy): Setup(previousConnectedPrivacy)
class RemoteChild(previous: SelectConnectedMode): FragmentStateLegacy(previous = previous, fragmentClass = SetupRemoteChildFragment::class.java)
class ParentMode(previous: SelectConnectedMode): FragmentStateLegacy(previous = previous, fragmentClass = SetupParentModeFragment::class.java)
sealed class ParentModeSetup(previous: State): Setup(previous)
data class ParentMailAuthentication(
val previousSelectConnectedMode: SelectConnectedMode,
val content: MailAuthentication.State = MailAuthentication.State.initial
): ParentModeSetup(previous = previousSelectConnectedMode)
class SignUpBlocked(previous: ParentMailAuthentication): ParentModeSetup(previous)
class SignInWrongMailAddress(previous: ConfirmNewParentAccount): ParentModeSetup(previous)
data class ConfirmNewParentAccount(
val previousParentMailAuthentication: ParentMailAuthentication,
val mailAuthToken: String,
val mailStatus: StatusOfMailAddressResponse
): ParentModeSetup(previous = previousParentMailAuthentication)
data class ParentBaseConfiguration(
val previousState: State,
val previousParentMailAuthentication: ParentMailAuthentication,
val mailAuthToken: String,
val mailStatus: StatusOfMailAddressResponse,
val deviceName: String,
val newUser: SetupParentHandling.NewUserDetails?
): ParentModeSetup(previousState) {
init {
if ((newUser != null) != (mailStatus.status == StatusOfMailAddress.MailAddressWithoutFamily))
throw IllegalStateException()
}
}
data class ParentConsent(
val baseConfig: ParentBaseConfiguration,
val backgroundSync: Boolean,
val notificationAccess: NotifyPermissionCard.Status,
val enableUpdates: Boolean,
val error: String?
): ParentModeSetup(baseConfig) {
init {
if (baseConfig.newUser != null && !baseConfig.newUser.ready) throw IllegalStateException()
}
}
}
class ParentMode: FragmentStateLegacy(previous = null, fragmentClass = ParentModeFragment::class.java)
object Purchase {

View file

@ -35,6 +35,10 @@ import java.io.Serializable
object MailAuthentication {
sealed class State: java.io.Serializable {
companion object {
val initial = State.EnterMailAddress()
}
abstract val error: ErrorDialog?
abstract fun withError(error: ErrorDialog?): State

View file

@ -23,11 +23,13 @@ import io.timelimit.android.ui.model.flow.Case
import io.timelimit.android.ui.model.flow.splitConflated
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
object SetupHandling {
fun handle(
logic: AppLogic,
activityCommand: SendChannel<ActivityCommand>,
permissionsChanged: SharedFlow<Unit>,
stateLive: Flow<State.Setup>,
updateState: ((State.Setup) -> State) -> Unit
): Flow<Screen> = stateLive.splitConflated(
@ -35,5 +37,6 @@ object SetupHandling {
Case.simple<_, _, State.Setup.DevicePermissions> { SetupLocalModePermissions.handle(logic, scope, activityCommand, it, updateMethod(updateState)) },
Case.simple<_, _, State.Setup.ConnectedPrivacy> { SetupConnectedModePrivacy.handle(logic, it, updateMethod(updateState)) },
Case.simple<_, _, State.Setup.SelectConnectedMode> { SetupSelectConnectedMode.handle(it, updateMethod(updateState)) },
Case.simple<_, _, State.Setup.ParentModeSetup> { SetupParentHandling.handle(logic, activityCommand, permissionsChanged, it, updateMethod(updateState)) }
)
}

View file

@ -0,0 +1,411 @@
/*
* 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.setup
import androidx.compose.material.SnackbarDuration
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.SnackbarResult
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.data.backup.DatabaseBackup
import io.timelimit.android.data.devicename.DeviceName
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.sync.ApplyServerDataStatus
import io.timelimit.android.sync.network.NewDeviceInfo
import io.timelimit.android.sync.network.ParentPassword
import io.timelimit.android.sync.network.ServerDataStatus
import io.timelimit.android.sync.network.StatusOfMailAddress
import io.timelimit.android.sync.network.api.ConflictHttpError
import io.timelimit.android.sync.network.api.UnauthorizedHttpError
import io.timelimit.android.ui.diagnose.exception.ExceptionUtil
import io.timelimit.android.ui.model.ActivityCommand
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 io.timelimit.android.ui.setup.SetupUnprovisionedCheck
import io.timelimit.android.ui.view.NotifyPermissionCard
import io.timelimit.android.update.UpdateIntegration
import io.timelimit.android.update.UpdateUtil
import io.timelimit.android.work.PeriodicSyncInBackgroundWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.io.Serializable
object SetupParentHandling {
data class NewUserDetails(
val parentName: String,
val password: String,
val password2: String
): Serializable {
companion object {
val empty = NewUserDetails("", "", "")
}
private val nameReady get() = parentName.isNotBlank()
private val passwordReady get() = password == password2 && password.isNotEmpty()
val ready get() = nameReady && passwordReady
}
data class ParentBaseConfiguration(
val mail: String,
val showLimitedProInfo: Boolean,
val deviceName: String,
val newUserDetails: NewUserDetails?,
val actions: Actions
) {
data class Actions(
val newUserActions: NewUserActions?,
val updateDeviceName: (String) -> Unit,
val next: (() -> Unit)?
)
data class NewUserActions(
val updateParentName: (String) -> Unit,
val updatePassword: (String) -> Unit,
val updatePassword2: (String) -> Unit,
)
}
data class ParentSetupConsent(
val backgroundSync: Boolean,
val notificationAccess: NotifyPermissionCard.Status,
val showEnableUpdates: Boolean,
val enableUpdates: Boolean,
val actions: Actions?
) {
data class Actions(
val updateBackgroundSync: (Boolean) -> Unit,
val updateNotificationAccess: (NotifyPermissionCard.Status) -> Unit,
val updateEnableUpdates: (Boolean) -> Unit,
val requestNotifyPermission: () -> Unit,
val skipNotifyPermission: () -> Unit,
val next: (() -> Unit)?
)
}
fun handle(
logic: AppLogic,
activityCommand: SendChannel<ActivityCommand>,
permissionsChanged: SharedFlow<Unit>,
stateLive: Flow<State.Setup.ParentModeSetup>,
updateState: ((State.Setup.ParentModeSetup) -> State) -> Unit
): Flow<Screen> = stateLive.splitConflated(
Case.simple<_, _, State.Setup.ParentMailAuthentication> { handleMailAuthentication(logic, scope, share(it), updateMethod(updateState)) },
Case.simple<_, _, State.Setup.SignUpBlocked> { handleSignUpBlocked(it) },
Case.simple<_, _, State.Setup.SignInWrongMailAddress> { handleSignUpWrongMailAddress(it) },
Case.simple<_, _, State.Setup.ConfirmNewParentAccount> { handleConfirmNewParentAccount(logic, it, updateMethod(updateState)) },
Case.simple<_, _, State.Setup.ParentBaseConfiguration> { handleParentBaseConfiguration(it, updateMethod(updateState)) },
Case.simple<_, _, State.Setup.ParentConsent> { handleParentConsent(logic, scope, activityCommand, permissionsChanged, share(it), updateMethod(updateState)) },
)
private fun handleMailAuthentication(
logic: AppLogic,
scope: CoroutineScope,
stateLive: SharedFlow<State.Setup.ParentMailAuthentication>,
updateState: ((State.Setup.ParentMailAuthentication) -> State) -> Unit
): Flow<Screen> = flow {
val snackbarHostState = SnackbarHostState()
val nestedLice = MailAuthentication.handle(
logic = logic,
scope = scope,
snackbarHostState = snackbarHostState,
stateLive = stateLive.map { it.content },
updateState = { modifier -> updateState { it.copy(content = modifier(it.content)) } },
processAuthToken = { token ->
val status = logic.serverLogic.getServerConfigCoroutine().api.getStatusByMailToken(token)
val deviceName = Threads.database.executeAndWait {
DeviceName.getDeviceNameSync(logic.context)
}
if (status.status == StatusOfMailAddress.MailAddressWithFamily) updateState {
val prev = it.copy(content = MailAuthentication.State.initial)
State.Setup.ParentBaseConfiguration(
previousState = prev,
previousParentMailAuthentication = prev,
mailAuthToken = token,
mailStatus = status,
deviceName = deviceName,
newUser = null
)
} else if (status.canCreateFamily) updateState { State.Setup.ConfirmNewParentAccount(it, token, status) }
else updateState { State.Setup.SignUpBlocked(it.copy(content = MailAuthentication.State.initial)) }
}
)
emitAll(combine(stateLive, nestedLice) { state, nested ->
Screen.SetupParentMailAuthentication(state, nested, snackbarHostState)
})
}
private fun handleSignUpBlocked(
stateLive: Flow<State.Setup.SignUpBlocked>
): Flow<Screen> = stateLive.map { Screen.SignupBlocked(it) }
private fun handleSignUpWrongMailAddress(
stateLive: Flow<State.Setup.SignInWrongMailAddress>
): Flow<Screen> = stateLive.map { Screen.SignInWrongMailAddress(it) }
private fun handleConfirmNewParentAccount(
logic: AppLogic,
stateLive: Flow<State.Setup.ConfirmNewParentAccount>,
updateState: ((State.Setup.ConfirmNewParentAccount) -> State) -> Unit
): Flow<Screen> = flow {
val deviceName = Threads.database.executeAndWait {
DeviceName.getDeviceNameSync(logic.context)
}
val confirm = { updateState {
State.Setup.ParentBaseConfiguration(
previousState = it,
previousParentMailAuthentication = it.previousParentMailAuthentication,
mailAuthToken = it.mailAuthToken,
mailStatus = it.mailStatus,
deviceName = deviceName,
newUser = NewUserDetails.empty
)
} }
val reject = { updateState {
State.Setup.SignInWrongMailAddress(it)
} }
emitAll(stateLive.map { Screen.ConfirmNewParentAccount(it, confirm = confirm, reject = reject) })
}
private fun handleParentBaseConfiguration(
stateLive: Flow<State.Setup.ParentBaseConfiguration>,
updateState: ((State.Setup.ParentBaseConfiguration) -> State) -> Unit
): Flow<Screen> {
fun deviceReady(state: State.Setup.ParentBaseConfiguration) = state.deviceName.isNotBlank()
fun newUserReady(state: State.Setup.ParentBaseConfiguration) = state.newUser == null || state.newUser.ready
fun ready(state: State.Setup.ParentBaseConfiguration) = newUserReady(state) && deviceReady(state)
val next = { updateState {
if (ready(it)) State.Setup.ParentConsent(
it,
backgroundSync = false,
notificationAccess = NotifyPermissionCard.Status.Unknown,
enableUpdates = false,
error = null
)
else it
}}
return stateLive.map { state ->
val newUserDetails = if (state.newUser != null) NewUserDetails(
parentName = state.newUser.parentName,
password = state.newUser.password,
password2 = state.newUser.password2
) to ParentBaseConfiguration.NewUserActions(
updateParentName = { v -> updateState { s -> s.copy(newUser = s.newUser?.copy(parentName = v)) } },
updatePassword = { v -> updateState { s -> s.copy(newUser = s.newUser?.copy(password = v)) } },
updatePassword2 = { v -> updateState { s -> s.copy(newUser = s.newUser?.copy(password2 = v)) } }
) else null
val actions = ParentBaseConfiguration.Actions(
newUserActions = newUserDetails?.second,
updateDeviceName = { v -> updateState { it.copy(deviceName = v) } },
next = if (ready(state)) next else null
)
val content = ParentBaseConfiguration(
mail = state.mailStatus.mail,
showLimitedProInfo = !state.mailStatus.alwaysPro,
deviceName = state.deviceName,
newUserDetails = newUserDetails?.first,
actions = actions
)
Screen.ParentBaseConfiguration(
state = state,
content = content
)
}
}
private fun handleParentConsent(
logic: AppLogic,
scope: CoroutineScope,
activityCommand: SendChannel<ActivityCommand>,
permissionsChanged: SharedFlow<Unit>,
stateLive: SharedFlow<State.Setup.ParentConsent>,
updateState: ((State.Setup.ParentConsent) -> State) -> Unit
): Flow<Screen> = flow {
val isWorkingLive = MutableStateFlow(false)
val snackbarHostState = SnackbarHostState()
var lastError: Job? = null
val actions = ParentSetupConsent.Actions(
updateBackgroundSync = { v -> updateState { it.copy(backgroundSync = v) } },
updateNotificationAccess = { v -> updateState { it.copy(notificationAccess = v) } },
updateEnableUpdates = { v -> updateState { it.copy(enableUpdates = v) } },
requestNotifyPermission = {
activityCommand.trySend(ActivityCommand.RequestNotifyPermission)
},
skipNotifyPermission = { updateState { it.copy(notificationAccess = NotifyPermissionCard.Status.SkipGrant) } },
next = { scope.launch { if (isWorkingLive.compareAndSet(expect = false, update = true)) try {
val state = stateLive.first()
val database = logic.database
val serverConfig = logic.serverLogic.getServerConfigCoroutine()
val api = serverConfig.api
lastError?.cancel(); lastError = null
val deviceModelName = Threads.database.executeAndWait {
DeviceName.getDeviceNameSync(logic.context)
}
val mailToken = state.baseConfig.mailAuthToken
val parentDevice = NewDeviceInfo(model = deviceModelName)
val deviceName = state.baseConfig.deviceName
val result = if (state.baseConfig.newUser != null) {
val parentPassword = ParentPassword.createCoroutine(state.baseConfig.newUser.password, null)
val parentName = state.baseConfig.newUser.parentName
val timeZone = logic.timeApi.getSystemTimeZone().id
val registerResponse = api.createFamilyByMailToken(
mailToken = mailToken,
parentPassword = parentPassword,
parentDevice = parentDevice,
deviceName = deviceName,
parentName = parentName,
timeZone = timeZone
)
Result(
deviceAuthToken = registerResponse.deviceAuthToken,
ownDeviceId = registerResponse.ownDeviceId,
serverDataStatus = registerResponse.data
)
} else {
val signInResponse = api.signInToFamilyByMailToken(
mailToken = mailToken,
parentDevice = parentDevice,
deviceName = deviceName
)
Result(
deviceAuthToken = signInResponse.deviceAuthToken,
ownDeviceId = signInResponse.ownDeviceId,
serverDataStatus = signInResponse.data
)
}
Threads.database.executeAndWait {
database.runInTransaction {
SetupUnprovisionedCheck.checkSync(logic.database)
database.deleteAllData()
database.config().setCustomServerUrlSync(serverConfig.customServerUrl)
database.config().setOwnDeviceIdSync(result.ownDeviceId)
database.config().setDeviceAuthTokenSync(result.deviceAuthToken)
database.config().setEnableBackgroundSync(state.backgroundSync)
ApplyServerDataStatus.applyServerDataStatusSync(result.serverDataStatus, logic.database, logic.platformIntegration)
}
}
DatabaseBackup.with(logic.context).tryCreateDatabaseBackupAsync()
if (state.backgroundSync) {
PeriodicSyncInBackgroundWorker.enable(logic.context)
}
UpdateUtil.setEnableChecks(logic.context, state.enableUpdates)
updateState { State.LaunchState }
} catch (ex: ConflictHttpError) {
snackbarHostState.showSnackbar(logic.context.getString(R.string.error_server_rejected))
updateState { it.baseConfig.previousParentMailAuthentication }
} catch (ex: UnauthorizedHttpError) {
snackbarHostState.showSnackbar(logic.context.getString(R.string.error_server_rejected))
updateState { it.baseConfig.previousParentMailAuthentication }
} catch (ex: Exception) {
lastError = launch {
val result = snackbarHostState.showSnackbar(
logic.context.getString(R.string.error_network),
logic.context.getString(R.string.generic_show_details),
SnackbarDuration.Long
)
if (result == SnackbarResult.ActionPerformed) updateState {
it.copy(error = ExceptionUtil.format(ex))
}
}
} finally {
isWorkingLive.value = false
} } }
)
scope.launch { permissionsChanged.collect {
updateState {
it.copy(notificationAccess = NotifyPermissionCard.updateStatus(it.notificationAccess, logic.context))
}
} }
val showEnableUpdates = UpdateIntegration.doesSupportUpdates(logic.context)
emitAll(combine(stateLive, isWorkingLive) { state, isWorking ->
val notificationAccess = NotifyPermissionCard.updateStatus(state.notificationAccess, logic.context)
val content = ParentSetupConsent(
backgroundSync = state.backgroundSync,
notificationAccess = notificationAccess,
enableUpdates = state.enableUpdates,
showEnableUpdates = showEnableUpdates,
actions = if (isWorking) null else actions.copy(
next = if (NotifyPermissionCard.canProceed(notificationAccess)) actions.next else null
)
)
Screen.ParentSetupConsent(
state = state,
content = content,
snackbarHostState = snackbarHostState,
errorDialog = state.error?.let { error ->
Pair(error) { updateState { it.copy(error = null) } }
}
)
})
}
internal data class Result(
val deviceAuthToken: String,
val ownDeviceId: String,
val serverDataStatus: ServerDataStatus
)
}

View file

@ -28,7 +28,7 @@ object SetupSelectConnectedMode {
return stateLive.map { state ->
Screen.SetupSelectConnectedModeScreen(
state = state,
mailLogin = { updateState { State.Setup.ParentMode(it) } },
mailLogin = { updateState { State.Setup.ParentMailAuthentication(it) } },
codeLogin = { updateState { State.Setup.RemoteChild(it) } }
)
}

View file

@ -0,0 +1,63 @@
/*
* 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.setup.parent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.OutlinedButton
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.timelimit.android.R
@Composable
fun ConfirmNewParentAccount(
confirm: () -> Unit,
reject: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
) {
Text(
stringResource(R.string.setup_parent_confirm_new_account_text),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally)
) {
OutlinedButton(onClick = confirm) {
Text(stringResource(R.string.generic_no))
}
Button(onClick = reject) {
Text(stringResource(R.string.generic_yes))
}
}
}
}

View file

@ -0,0 +1,105 @@
/*
* 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.setup.parent
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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.material.TextField
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.input.ImeAction
import androidx.compose.ui.unit.dp
import io.timelimit.android.R
import io.timelimit.android.ui.model.setup.SetupParentHandling
import io.timelimit.android.ui.view.EnterTextField
import io.timelimit.android.ui.view.SetPassword
@Composable
fun ParentBaseConfiguration(
content: SetupParentHandling.ParentBaseConfiguration,
modifier: Modifier = Modifier
) {
Column(
modifier
.verticalScroll(rememberScrollState())
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (content.newUserDetails != null) {
run {
Text(stringResource(R.string.setup_parent_mode_explaination_user_name))
TextField(
value = content.newUserDetails.parentName,
onValueChange = content.actions.newUserActions?.updateParentName ?: {},
enabled = content.actions.newUserActions?.updateParentName != null,
label = {
Text(stringResource(R.string.setup_parent_mode_field_name_hint))
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
}
run {
Text(stringResource(R.string.setup_parent_mode_explaination_password, content.mail))
SetPassword(
password1 = content.newUserDetails.password,
password2 = content.newUserDetails.password2,
updatePassword1 = content.actions.newUserActions?.updatePassword ?: {},
updatePassword2 = content.actions.newUserActions?.updatePassword2 ?: {},
enabled = content.actions.newUserActions != null
)
}
}
run {
Text(stringResource(R.string.setup_parent_mode_explaination_device_title))
EnterTextField(
value = content.deviceName,
onValueChange = content.actions.updateDeviceName,
label = {
Text(stringResource(R.string.setup_parent_mode_explaination_device_title_field_hint))
},
modifier = Modifier.fillMaxWidth(),
onConfirmInput = content.actions.next ?: {}
)
}
if (content.showLimitedProInfo) Text(stringResource(R.string.purchase_demo_temporarily_notice))
Button(
onClick = content.actions.next ?: {},
enabled = content.actions.next != null,
modifier = Modifier.align(Alignment.End)
) {
Text(stringResource(R.string.wiazrd_next))
}
}
}

View file

@ -0,0 +1,123 @@
/*
* 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.setup.parent
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.Card
import androidx.compose.material.MaterialTheme
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.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.ui.diagnose.exception.DiagnoseExceptionDialog
import io.timelimit.android.ui.model.setup.SetupParentHandling
import io.timelimit.android.ui.view.NotifyPermissionCard
import io.timelimit.android.ui.view.SwitchRow
import io.timelimit.android.update.UpdateUtil
@Composable
fun ParentSetupConsent(
content: SetupParentHandling.ParentSetupConsent,
errorDialog: Pair<String, () -> Unit>?,
modifier: Modifier = Modifier
) {
Column(
modifier
.verticalScroll(rememberScrollState())
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ConsentCard(
stringResource(R.string.device_background_sync_title),
stringResource(R.string.device_background_sync_text),
stringResource(R.string.device_background_sync_checkbox),
checked = content.backgroundSync,
onCheckedChanged = content.actions?.updateBackgroundSync ?: {/* do nothing */},
enabled = content.actions?.updateBackgroundSync != null
)
if (content.showEnableUpdates) ConsentCard(
stringResource(R.string.update),
stringResource(R.string.update_privacy, BuildConfig.updateServer),
stringResource(R.string.update_enable_switch),
checked = content.enableUpdates,
onCheckedChanged = content.actions?.updateEnableUpdates ?: {/* do nothing */},
enabled = content.actions?.updateEnableUpdates != null
)
NotifyPermissionCard.View(
status = content.notificationAccess,
listener = object: NotifyPermissionCard.Listener {
override fun onGrantClicked() { content.actions?.requestNotifyPermission?.invoke() }
override fun onSkipClicked() { content.actions?.skipNotifyPermission?.invoke() }
},
enabled = content.actions != null
)
Button(
onClick = content.actions?.next ?: {},
enabled = content.actions?.next != null,
modifier = Modifier.align(Alignment.End)
) {
Text(stringResource(R.string.wiazrd_next))
}
}
if (errorDialog != null) DiagnoseExceptionDialog(errorDialog.first, errorDialog.second)
}
@Composable
fun ConsentCard(
title: String,
text: String,
switch: String,
checked: Boolean,
onCheckedChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
Card {
Column(
modifier.fillMaxWidth().padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
title,
style = MaterialTheme.typography.h5
)
Text(text)
SwitchRow(
label = switch,
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChanged,
reverse = true
)
}
}
}

View file

@ -1,221 +0,0 @@
/*
* 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.setup.parent
import android.Manifest
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import androidx.lifecycle.*
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.data.devicename.DeviceName
import io.timelimit.android.databinding.FragmentSetupParentModeBinding
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.network.StatusOfMailAddress
import io.timelimit.android.ui.authentication.AuthenticateByMailFragment
import io.timelimit.android.ui.authentication.AuthenticateByMailFragmentListener
import io.timelimit.android.ui.update.UpdateConsentCard
import io.timelimit.android.ui.view.NotifyPermissionCard
class SetupParentModeFragment : Fragment(), AuthenticateByMailFragmentListener {
companion object {
private const val STATUS_NOTIFY_PERMISSION = "notify permission"
}
private val model: SetupParentModeModel by lazy { ViewModelProviders.of(this).get(SetupParentModeModel::class.java) }
private var notifyPermission = MutableLiveData<NotifyPermissionCard.Status>()
private val requestNotifyPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) notifyPermission.value = NotifyPermissionCard.Status.Granted
else Toast.makeText(requireContext(), R.string.notify_permission_rejected_toast, Toast.LENGTH_SHORT).show()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null) {
notifyPermission.value = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
savedInstanceState.getSerializable(STATUS_NOTIFY_PERMISSION, NotifyPermissionCard.Status::class.java)!!
else
savedInstanceState.getSerializable(STATUS_NOTIFY_PERMISSION)!! as NotifyPermissionCard.Status
}
notifyPermission.value = NotifyPermissionCard.updateStatus(notifyPermission.value ?: NotifyPermissionCard.Status.Unknown, requireContext())
}
override fun onResume() {
super.onResume()
notifyPermission.value = NotifyPermissionCard.updateStatus(notifyPermission.value ?: NotifyPermissionCard.Status.Unknown, requireContext())
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putSerializable(STATUS_NOTIFY_PERMISSION, notifyPermission.value)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = FragmentSetupParentModeBinding.inflate(layoutInflater, container, false)
model.mailAuthToken.switchMap {
mailAuthToken ->
if (mailAuthToken == null) {
liveDataFromNonNullValue(1) // show login screen
} else {
// show form or loading indicator or error screen
model.statusOfMailAddress.switchMap {
status ->
if (status == null) {
liveDataFromNonNullValue(2) // loading screen
} else if (status.status == StatusOfMailAddress.MailAddressWithoutFamily && status.canCreateFamily == false) {
liveDataFromNonNullValue(3) // signup disabled screen
} else {
model.isDoingSetup.map {
if (it!!) {
2 // loading screen
} else {
0 // the form
}
}
}
}
}
}.observe(viewLifecycleOwner, Observer {
binding.switcher.displayedChild = it!!
})
model.statusOfMailAddress.observe(viewLifecycleOwner, Observer {
if (it != null) {
binding.isNewFamily = when (it.status) {
StatusOfMailAddress.MailAddressWithoutFamily -> true
StatusOfMailAddress.MailAddressWithFamily -> false
}
binding.showLimitedProInfo = !it.alwaysPro
binding.mail = it.mail
}
})
val isPasswordValid = model.statusOfMailAddress.switchMap {
if (it == null) {
liveDataFromNonNullValue(false)
} else {
when (it.status) {
StatusOfMailAddress.MailAddressWithFamily -> liveDataFromNonNullValue(true)
StatusOfMailAddress.MailAddressWithoutFamily -> binding.password.passwordOk
}
}
}
val isNotifyPermissionValid = notifyPermission.map { NotifyPermissionCard.canProceed(it) }
val isPreNameValid = model.statusOfMailAddress.switchMap {
if (it == null) {
liveDataFromNonNullValue(false)
} else {
when (it.status) {
StatusOfMailAddress.MailAddressWithFamily -> liveDataFromNonNullValue(true)
StatusOfMailAddress.MailAddressWithoutFamily -> binding.prename.getTextLive().map { prename -> prename.isNotBlank() }
}
}
}
val isDeviceNameValid = binding.deviceName.getTextLive().map { it.isNotBlank() }
val isInputValid = isPasswordValid.and(isNotifyPermissionValid).and(isPreNameValid).and(isDeviceNameValid)
isInputValid.ignoreUnchanged().observe(viewLifecycleOwner, Observer {
binding.enableOkButton = it!!
})
if (savedInstanceState == null) {
val ctx = requireContext()
runAsync {
// provide an useful default value
val deviceName = Threads.database.executeAndWait { DeviceName.getDeviceNameSync(ctx) }
binding.deviceName.setText(deviceName)
}
}
binding.ok.setOnClickListener {
val status = model.statusOfMailAddress.value
if (status == null) {
throw IllegalStateException()
}
when (status.status) {
StatusOfMailAddress.MailAddressWithoutFamily -> {
model.createFamily(
parentPassword = binding.password.readPassword(),
parentName = binding.prename.text.toString(),
deviceName = binding.deviceName.text.toString(),
enableBackgroundSync = binding.backgroundSyncCheckbox.isChecked,
enableUpdateChecks = binding.update.enableSwitch.isChecked
)
}
StatusOfMailAddress.MailAddressWithFamily -> {
model.addDeviceToFamily(
deviceName = binding.deviceName.text.toString(),
enableBackgroundSync = binding.backgroundSyncCheckbox.isChecked,
enableUpdateChecks = binding.update.enableSwitch.isChecked
)
}
}
}
UpdateConsentCard.bind(
view = binding.update,
lifecycleOwner = viewLifecycleOwner,
database = DefaultAppLogic.with(requireContext()).database
)
NotifyPermissionCard.bind(object: NotifyPermissionCard.Listener {
override fun onGrantClicked() { requestNotifyPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }
override fun onSkipClicked() { notifyPermission.value = NotifyPermissionCard.Status.SkipGrant }
}, binding.notifyPermissionCard)
notifyPermission.observe(viewLifecycleOwner) { NotifyPermissionCard.bind(it, binding.notifyPermissionCard) }
return binding.root
}
override fun onLoginSucceeded(mailAuthToken: String) = model.setMailToken(mailAuthToken)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (savedInstanceState == null) {
childFragmentManager.beginTransaction()
.replace(R.id.mail_auth_container, AuthenticateByMailFragment())
.commit()
}
}
}

View file

@ -1,225 +0,0 @@
/*
* 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.setup.parent
import android.app.Application
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.data.backup.DatabaseBackup
import io.timelimit.android.data.devicename.DeviceName
import io.timelimit.android.livedata.castDown
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.ApplyServerDataStatus
import io.timelimit.android.sync.network.NewDeviceInfo
import io.timelimit.android.sync.network.ParentPassword
import io.timelimit.android.sync.network.StatusOfMailAddressResponse
import io.timelimit.android.sync.network.api.ConflictHttpError
import io.timelimit.android.sync.network.api.UnauthorizedHttpError
import io.timelimit.android.ui.setup.SetupUnprovisionedCheck
import io.timelimit.android.update.UpdateUtil
import io.timelimit.android.work.PeriodicSyncInBackgroundWorker
class SetupParentModeModel(application: Application): AndroidViewModel(application) {
companion object {
private const val LOG_TAG = "SetupParentModeModel"
}
private val logic = DefaultAppLogic.with(application)
private val mailAuthTokenInternal = MutableLiveData<String?>().apply { value = null }
private val statusOfMailAddressInternal = MutableLiveData<StatusOfMailAddressResponse?>().apply { value = null }
private val isDoingSetupInternal = MutableLiveData<Boolean>().apply { value = false }
val mailAuthToken = mailAuthTokenInternal.castDown()
val statusOfMailAddress = statusOfMailAddressInternal.castDown()
val isDoingSetup = isDoingSetupInternal.castDown()
val isSetupDone = logic.database.config().getOwnDeviceId().map { it != null }
fun setMailToken(mailAuthToken: String) {
if (this.mailAuthTokenInternal.value == null) {
this.mailAuthTokenInternal.value = mailAuthToken
this.statusOfMailAddressInternal.value = null
runAsync {
try {
val api = logic.serverLogic.getServerConfigCoroutine().api
val status = api.getStatusByMailToken(mailAuthToken)
statusOfMailAddressInternal.value = status
} catch (ex: Exception) {
Toast.makeText(getApplication(), R.string.error_network, Toast.LENGTH_SHORT).show()
mailAuthTokenInternal.value = null
}
}
}
}
fun createFamily(parentPassword: String, parentName: String, deviceName: String, enableBackgroundSync: Boolean, enableUpdateChecks: Boolean) {
val database = logic.database
if (isDoingSetup.value!!) {
return
}
isDoingSetupInternal.value = true
runAsync {
try {
val api = logic.serverLogic.getServerConfigCoroutine().api
val deviceModelName = Threads.database.executeAndWait { DeviceName.getDeviceNameSync(getApplication()) }
val registerResponse = api.createFamilyByMailToken(
mailToken = mailAuthToken.value!!,
parentPassword = ParentPassword.createCoroutine(parentPassword, null),
parentDevice = NewDeviceInfo(
model = deviceModelName
),
deviceName = deviceName,
parentName = parentName,
timeZone = logic.timeApi.getSystemTimeZone().id
)
val clientStatusResponse = registerResponse.data
Threads.database.executeAndWait {
logic.database.runInTransaction {
val customServerUrl = logic.database.config().getCustomServerUrlSync()
SetupUnprovisionedCheck.checkSync(logic.database)
database.deleteAllData()
database.config().setCustomServerUrlSync(customServerUrl)
database.config().setOwnDeviceIdSync(registerResponse.ownDeviceId)
database.config().setDeviceAuthTokenSync(registerResponse.deviceAuthToken)
database.config().setEnableBackgroundSync(enableBackgroundSync)
ApplyServerDataStatus.applyServerDataStatusSync(clientStatusResponse, logic.database, logic.platformIntegration)
}
}
DatabaseBackup.with(getApplication()).tryCreateDatabaseBackupAsync()
if (enableBackgroundSync) {
PeriodicSyncInBackgroundWorker.enable(getApplication())
}
UpdateUtil.setEnableChecks(getApplication(), enableUpdateChecks)
// the fragment detects the success and leaves this screen
} catch (ex: ConflictHttpError) {
mailAuthTokenInternal.value = null
isDoingSetupInternal.value = false
Toast.makeText(getApplication(), R.string.error_server_rejected, Toast.LENGTH_SHORT).show()
} catch (ex: UnauthorizedHttpError) {
isDoingSetupInternal.value = false
mailAuthTokenInternal.value = null
Toast.makeText(getApplication(), R.string.error_server_rejected, Toast.LENGTH_SHORT).show()
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "error during setup", ex)
}
isDoingSetupInternal.value = false
Toast.makeText(getApplication(), R.string.error_network, Toast.LENGTH_SHORT).show()
}
}
}
fun addDeviceToFamily(deviceName: String, enableBackgroundSync: Boolean, enableUpdateChecks: Boolean) {
val database = logic.database
if (isDoingSetup.value!!) {
return
}
isDoingSetupInternal.value = true
runAsync {
try {
val api = logic.serverLogic.getServerConfigCoroutine().api
val deviceModelName = Threads.database.executeAndWait { DeviceName.getDeviceNameSync(getApplication()) }
val registerResponse = api.signInToFamilyByMailToken(
mailToken = mailAuthToken.value!!,
parentDevice = NewDeviceInfo(
model = deviceModelName
),
deviceName = deviceName
)
val clientStatusResponse = registerResponse.data
Threads.database.executeAndWait {
logic.database.runInTransaction {
val customServerUrl = logic.database.config().getCustomServerUrlSync()
SetupUnprovisionedCheck.checkSync(logic.database)
database.deleteAllData()
database.config().setCustomServerUrlSync(customServerUrl)
database.config().setOwnDeviceIdSync(registerResponse.ownDeviceId)
database.config().setDeviceAuthTokenSync(registerResponse.deviceAuthToken)
database.config().setEnableBackgroundSync(enableBackgroundSync)
ApplyServerDataStatus.applyServerDataStatusSync(clientStatusResponse, logic.database, logic.platformIntegration)
}
}
DatabaseBackup.with(getApplication()).tryCreateDatabaseBackupAsync()
if (enableBackgroundSync) {
PeriodicSyncInBackgroundWorker.enable(getApplication())
}
UpdateUtil.setEnableChecks(getApplication(), enableUpdateChecks)
// the fragment detects the success and leaves this screen
} catch (ex: ConflictHttpError) {
isDoingSetupInternal.value = false
mailAuthTokenInternal.value = null
Toast.makeText(getApplication(), R.string.error_server_rejected, Toast.LENGTH_SHORT).show()
} catch (ex: UnauthorizedHttpError) {
isDoingSetupInternal.value = false
mailAuthTokenInternal.value = null
Toast.makeText(getApplication(), R.string.error_server_rejected, Toast.LENGTH_SHORT).show()
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "error during setup", ex)
}
isDoingSetupInternal.value = false
Toast.makeText(getApplication(), R.string.error_network, Toast.LENGTH_SHORT).show()
}
}
}
}

View file

@ -0,0 +1,51 @@
/*
* 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.setup.parent
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.material.MaterialTheme
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.timelimit.android.R
@Composable
fun SignInWrongMailAddress(modifier: Modifier) {
Column(
modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
) {
Text(
stringResource(R.string.setup_parent_mode_error_wrong_mail_address_title),
style = MaterialTheme.typography.h5,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
stringResource(R.string.setup_parent_mode_error_wrong_mail_address_text),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}

View file

@ -0,0 +1,42 @@
/*
* 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.setup.parent
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.material.Text
import androidx.compose.runtime.Composable
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
@Composable
fun SignupBlockedScreen(modifier: Modifier) {
Column(
modifier.padding(16.dp),
verticalArrangement = Arrangement.SpaceAround
) {
Text(
stringResource(R.string.setup_parent_mode_error_signup_disabled),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -18,7 +18,26 @@ package io.timelimit.android.ui.view
import android.app.NotificationManager
import android.content.Context
import android.os.Build
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.material.Button
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import io.timelimit.android.R
import io.timelimit.android.databinding.NotifyPermissionCardBinding
object NotifyPermissionCard {
@ -29,6 +48,49 @@ object NotifyPermissionCard {
Granted
}
@Composable
fun View(
status: Status,
listener: Listener,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
Card { Column(
modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(R.string.notify_permission_title),
style = MaterialTheme.typography.h5
)
Text(
stringResource(R.string.notify_permission_text)
)
if (status == Status.Granted) Text(
stringResource(R.string.notify_permission_text_granted),
color = Color(ContextCompat.getColor(LocalContext.current, R.color.text_green)).copy(alpha = 1f),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
) else if (status == Status.WaitingForInteraction) TextButton(
onClick = { listener.onSkipClicked() },
modifier = Modifier.align(Alignment.End),
enabled = enabled
) {
Text(stringResource(R.string.notify_permission_btn_later))
}
if (status != Status.Granted) Button(
onClick = { listener.onGrantClicked() },
modifier = Modifier.align(Alignment.End),
enabled = enabled
) {
Text(stringResource(R.string.notify_permission_btn_grant))
}
} }
}
fun bind(status: Status, view: NotifyPermissionCardBinding) {
view.showGrantButton = status != Status.Granted
view.showGrantedMessage = status == Status.Granted

View file

@ -0,0 +1,100 @@
/*
* 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.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.timelimit.android.R
@Composable
fun SetPassword(
password1: String,
password2: String,
updatePassword1: (String) -> Unit,
updatePassword2: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean
) {
Column(
modifier,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
TextField(
value = password1,
onValueChange = updatePassword1,
enabled = enabled,
label = {
Text(stringResource(R.string.set_password_view_label_password))
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
visualTransformation = PasswordVisualTransformation()
)
AnimatedVisibility(visible = password1.isEmpty()) {
Text(
stringResource(R.string.password_validator_empty),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colors.secondary
)
}
TextField(
value = password2,
onValueChange = updatePassword2,
enabled = enabled,
label = {
Text(stringResource(R.string.set_password_view_label_password_repeat))
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
visualTransformation = PasswordVisualTransformation()
)
AnimatedVisibility(visible = password1 != password2) {
Text(
stringResource(R.string.set_password_view_not_identical),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colors.secondary
)
}
}
}

View file

@ -17,6 +17,7 @@ package io.timelimit.android.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -36,7 +37,8 @@ fun SwitchRow(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
enabled: Boolean = true,
reverse: Boolean = false
) {
Row (
modifier
@ -51,14 +53,21 @@ fun SwitchRow(
),
verticalAlignment = Alignment.CenterVertically
) {
if (reverse) {
Text(label)
Spacer(Modifier.width(8.dp))
Spacer(Modifier.weight(1f))
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled
)
Spacer(Modifier.width(8.dp))
Text(label)
if (!reverse) {
Spacer(Modifier.width(8.dp))
Text(label)
}
}
}

View file

@ -1,173 +0,0 @@
<!--
TimeLimit Copyright <C> 2019 - 2022 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/>.
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context="io.timelimit.android.ui.setup.parent.SetupParentModeFragment">
<data>
<variable
name="isNewFamily"
type="Boolean" />
<variable
name="mail"
type="String" />
<variable
name="enableOkButton"
type="Boolean" />
<variable
name="showLimitedProInfo"
type="boolean" />
<import type="android.view.View" />
</data>
<io.timelimit.android.ui.view.SafeViewFlipper
android:id="@+id/switcher"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:id="@+id/scroll"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:orientation="vertical"
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.cardview.widget.CardView
app:cardUseCompatPadding="true"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:padding="8dp"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:textAppearance="?android:textAppearanceLarge"
tools:text="@string/setup_parent_mode_create_family"
android:text="@{safeUnbox(isNewFamily) ? @string/setup_parent_mode_create_family : @string/setup_parent_mode_add_device}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{safeUnbox(isNewFamily) ? View.VISIBLE : View.GONE}"
android:textAppearance="?android:textAppearanceMedium"
android:text="@string/setup_parent_mode_explaination_user_name"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<EditText
android:inputType="textPersonName"
android:id="@+id/prename"
android:visibility="@{safeUnbox(isNewFamily) ? View.VISIBLE : View.GONE}"
android:hint="@string/setup_parent_mode_field_name_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<requestFocus />
</EditText>
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:visibility="@{safeUnbox(isNewFamily) ? View.VISIBLE : View.GONE}"
tools:text="@string/setup_parent_mode_explaination_password"
android:text="@{@string/setup_parent_mode_explaination_password(mail)}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<io.timelimit.android.ui.view.SetPasswordView
android:visibility="@{safeUnbox(isNewFamily) ? View.VISIBLE : View.GONE}"
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:text="@string/setup_parent_mode_explaination_device_title"
android:textAppearance="?android:textAppearanceMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<EditText
android:inputType="text"
android:id="@+id/device_name"
android:hint="@string/setup_parent_mode_explaination_device_title_field_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:text="@string/device_background_sync_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<CheckBox
android:text="@string/device_background_sync_checkbox"
android:id="@+id/background_sync_checkbox"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{showLimitedProInfo ? View.VISIBLE : View.GONE}"
android:textAppearance="?android:textAppearanceSmall"
android:text="@string/purchase_demo_temporarily_notice"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<include layout="@layout/notify_permission_card"
android:id="@+id/notify_permission_card" />
<include
layout="@layout/update_consent_card"
android:id="@+id/update" />
<Button
android:layout_marginTop="8dp"
android:layout_marginEnd="4dp"
android:enabled="@{safeUnbox(enableOkButton)}"
android:id="@+id/ok"
android:layout_gravity="end"
android:text="@string/generic_ok"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</ScrollView>
<FrameLayout
android:id="@+id/mail_auth_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<include layout="@layout/circular_progress_indicator" />
<TextView
android:text="@string/setup_parent_mode_error_signup_disabled"
android:textAppearance="?android:textAppearanceMedium"
android:padding="16dp"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</io.timelimit.android.ui.view.SafeViewFlipper>
</layout>

View file

@ -1296,6 +1296,7 @@
dann installieren Sie TimeLimit erneut.
</string>
<string name="password_validator_empty">Das Passwort darf nicht leer sein</string>
<string name="password_validator_too_short">Das Passwort muss mindestens <xliff:g example="5" id="number of chars">%d</xliff:g> Zeichen haben</string>
<string name="primary_device_title">Aktuelles Gerät</string>
@ -1471,6 +1472,18 @@
<string name="setup_parent_mode_error_signup_disabled">Momentan sind keine Neuanmeldungen möglich</string>
<string name="setup_parent_mode_error_wrong_mail_address_title">falsche E-Mail-Adresse</string>
<string name="setup_parent_mode_error_wrong_mail_address_text">Es liegt keine Registrierung mit
der von Ihnen angegebenen E-Mail-Adresse vor. Finden Sie heraus, mit welcher E-Mail-Adresse
Sie sich ursprünglich angemeldet haben. Ansonsten gehen die bestehenden Verknüpfungen mit
anderen Geräten und eine evtl. vorhandene Vollversion verloren. Sie können die E-Mail-Adresse
bei noch bestehenden TimeLimit-Installationen einsehen oder in Ihren Postfächern nachsehen,
wohin damals die Anmelde-E-Mail gesendet wurde. Der Support kann Ihnen aus Datenschutzgründen
nicht mitteilen, welche E-Mail-Adresse Ihren Namen trägt und registriert ist.
</string>
<string name="setup_parent_confirm_new_account_text">Haben Sie sich bereits für TimeLimit mit Vernetzung registriert oder haben Sie bereits die Vollversion erworben?</string>
<string name="setup_privacy_connected_title">Vernetzung und Datenschutz</string>
<string name="setup_privacy_connected_text_general_intro">Für den vernetzten Modus
gibt es eine Zentrale - den Server. Auf diesen werden die Einstellungen,

View file

@ -1342,6 +1342,7 @@
reset/reinstall TimeLimit.
</string>
<string name="password_validator_empty">The password must not be empty</string>
<string name="password_validator_too_short">The password must have at least <xliff:g example="5" id="number of chars">%d</xliff:g> characters</string>
<string name="primary_device_title">Current device</string>
@ -1513,6 +1514,18 @@
<string name="setup_parent_mode_error_signup_disabled">It is currently not possible to create new accounts</string>
<string name="setup_parent_mode_error_wrong_mail_address_title">wrong mail address</string>
<string name="setup_parent_mode_error_wrong_mail_address_text">There is no registration
for the mail address that you provided. Do some research to find the correct one.
Otherwise, you will loose any premium version and linked devices. You can see the
mail address at other devices if TimeLimit is still installed. Moreover, you can check
your mailboxes to see at which mail address the login mail was sent in the past.
For privacy reasons, the support cannot tell you which registered mail addresses
with your name exist.
</string>
<string name="setup_parent_confirm_new_account_text">Did you already sign up for the connected mode/did you buy the premium version already?</string>
<string name="setup_privacy_connected_title">Networking and Privacy</string>
<string name="setup_privacy_connected_text_general_intro">For the connected mode,
there is a central unit - the server. This server saves the settings, the usage durations