diff --git a/Habitica/res/values/strings.xml b/Habitica/res/values/strings.xml index b196b73d7..fbee87d8d 100644 --- a/Habitica/res/values/strings.xml +++ b/Habitica/res/values/strings.xml @@ -1641,4 +1641,19 @@ %d Item pending %d Items pending + Server options + Server configuration + Server URL + https://habitica.com + Apply + Reset to default + Couldn’t understand that server address. Include http:// or https:// and a host name. + Warning: Only use these developer settings if you know what you’re doing. + + Developer options unlock in %d tap. + Developer options unlock in %d taps. + + Developer options unlocked. + UnifiedPush server URL + https://example.com diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/Analytics.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/Analytics.kt index 79a31a57c..6f5602810 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/Analytics.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/Analytics.kt @@ -76,14 +76,19 @@ object Analytics { } fun initialize(context: Context) { - amplitude = - Amplitude( - Configuration( - context.getString(R.string.amplitude_app_id), - context, - optOut = true, + val amplitudeAppId = context.getString(R.string.amplitude_app_id) + if (amplitudeAppId.isNullOrBlank()) { + // No amplitude configuration provided; skip amplitude setup for this build. + } else { + amplitude = + Amplitude( + Configuration( + amplitudeAppId, + context, + optOut = true, + ) ) - ) + } firebase = FirebaseAnalytics.getInstance(context) } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/notifications/PushNotificationManager.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/notifications/PushNotificationManager.kt index 7d1129e55..d065d1626 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/notifications/PushNotificationManager.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/notifications/PushNotificationManager.kt @@ -76,6 +76,7 @@ class PushNotificationManager( // catchy catch } } + addUnifiedPushDeviceIfConfigured() } private fun addRefreshToken() { @@ -90,6 +91,22 @@ class PushNotificationManager( } } + private fun addUnifiedPushDeviceIfConfigured() { + val unifiedPushUrl = sharedPreferences.getString(UNIFIED_PUSH_SERVER_KEY, null)?.trim() + if (unifiedPushUrl.isNullOrEmpty()) { + return + } + if (this.user == null) { + return + } + val pushDeviceData = HashMap() + pushDeviceData["regId"] = unifiedPushUrl + pushDeviceData["type"] = "unifiedpush" + MainScope().launchCatching { + apiClient.addPushDevice(pushDeviceData) + } + } + suspend fun removePushDeviceUsingStoredToken() { if (this.refreshedToken.isEmpty() || !userHasPushDevice()) { return @@ -140,6 +157,7 @@ class PushNotificationManager( const val CONTENT_RELEASE_NOTIFICATION_KEY = "contentRelease" const val G1G1_PROMO_KEY = "g1g1Promo" const val DEVICE_TOKEN_PREFERENCE_KEY = "device-token-preference" + private const val UNIFIED_PUSH_SERVER_KEY = "unified_push_server_url" fun displayNotification( remoteMessage: RemoteMessage, diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/OnboardingActivity.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/OnboardingActivity.kt index dd3ce833d..ad86fa091 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/OnboardingActivity.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/OnboardingActivity.kt @@ -71,7 +71,7 @@ class OnboardingActivity: BaseActivity() { val authenticationViewModel: AuthenticationViewModel by viewModels() - val currentStep = mutableStateOf(OnboardingSteps.SETUP) + val currentStep = mutableStateOf(OnboardingSteps.LOGIN) @Inject lateinit var configManager: AppConfigManager @@ -91,6 +91,11 @@ class OnboardingActivity: BaseActivity() { // Set default values to avoid null-responses when requesting unedited settings PreferenceManager.setDefaultValues(this, R.xml.preferences_fragment, false) + if (authenticationViewModel.hostConfig.hasAuthentication()) { + startMainActivity() + return + } + binding.composeView.setContent { val step by currentStep HabiticaTheme { diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/AuthenticationViewModel.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/AuthenticationViewModel.kt index d0849ef59..732baac01 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/AuthenticationViewModel.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/AuthenticationViewModel.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import javax.inject.Inject +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @HiltViewModel class AuthenticationViewModel @Inject constructor( @@ -78,6 +79,66 @@ class AuthenticationViewModel @Inject constructor( private var _usernameIssues = MutableStateFlow(null) val usernameIssues: Flow = _usernameIssues + fun currentServerSelection(): String { + return sharedPrefs.getString(SERVER_OVERRIDE_KEY, hostConfig.address) ?: hostConfig.address + } + + fun currentUnifiedPushServer(): String { + return sharedPrefs.getString(UNIFIED_PUSH_SERVER_KEY, "") ?: "" + } + + fun isDevOptionsUnlocked(): Boolean { + return sharedPrefs.getBoolean(DEV_OPTIONS_UNLOCKED_KEY, false) + } + + fun setDevOptionsUnlocked(unlocked: Boolean) { + sharedPrefs.edit { + putBoolean(DEV_OPTIONS_UNLOCKED_KEY, unlocked) + } + } + + fun applyServerOverride(serverUrl: String?): Boolean { + val normalized = normalizeServerUrl(serverUrl) + ?: return false + + sharedPrefs.edit { + putString(SERVER_OVERRIDE_KEY, normalized) + } + + apiClient.updateServerUrl(normalized) + return true + } + + fun updateUnifiedPushServer(serverUrl: String?) { + val sanitized = serverUrl?.trim()?.takeIf { it.isNotEmpty() } + sharedPrefs.edit { + if (sanitized != null) { + putString(UNIFIED_PUSH_SERVER_KEY, sanitized) + } else { + remove(UNIFIED_PUSH_SERVER_KEY) + } + } + } + + fun resetServerOverride() { + sharedPrefs.edit { + remove(SERVER_OVERRIDE_KEY) + remove(UNIFIED_PUSH_SERVER_KEY) + if (!isDevOptionsUnlocked()) { + remove(DEV_OPTIONS_UNLOCKED_KEY) + } + } + apiClient.updateServerUrl(BuildConfig.BASE_URL) + } + + private fun normalizeServerUrl(input: String?): String? { + val trimmed = input?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val candidate = if (trimmed.contains("://")) trimmed else "https://$trimmed" + val httpUrl = runCatching { candidate.toHttpUrlOrNull() }.getOrNull() ?: return null + val rebuilt = httpUrl.newBuilder().encodedPath("/").build().toString() + return rebuilt.trimEnd('/') + } + fun validateInputs( username: String, password: String, @@ -273,4 +334,10 @@ class AuthenticationViewModel @Inject constructor( } } } + + companion object { + private const val SERVER_OVERRIDE_KEY = "server_url" + private const val UNIFIED_PUSH_SERVER_KEY = "unified_push_server_url" + private const val DEV_OPTIONS_UNLOCKED_KEY = "dev_options_unlocked" + } } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/login/LoginScreen.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/login/LoginScreen.kt index f965914da..d1867f515 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/login/LoginScreen.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/login/LoginScreen.kt @@ -1,6 +1,7 @@ package com.habitrpg.android.habitica.ui.views.login import android.util.Patterns +import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.EaseInOut import androidx.compose.animation.core.animateDpAsState @@ -13,6 +14,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -22,10 +24,16 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -38,6 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -74,6 +83,24 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi var passwordFieldState by remember { mutableStateOf(LoginFieldState.DEFAULT) } var email by authenticationViewModel.email var emailFieldState by remember { mutableStateOf(LoginFieldState.DEFAULT) } + var showServerDialog by remember { mutableStateOf(false) } + var customServerUrl by remember { mutableStateOf(authenticationViewModel.currentServerSelection()) } + var unifiedPushUrl by remember { mutableStateOf(authenticationViewModel.currentUnifiedPushServer()) } + var serverError by remember { mutableStateOf(null) } + val invalidServerMessage = stringResource(R.string.custom_server_invalid) + val context = LocalContext.current + var devTapCount by remember { mutableStateOf(0) } + var devOptionsUnlocked by remember { mutableStateOf(authenticationViewModel.isDevOptionsUnlocked()) } + val requiredTapCount = 7 + + LaunchedEffect(showServerDialog) { + if (showServerDialog) { + customServerUrl = authenticationViewModel.currentServerSelection() + unifiedPushUrl = authenticationViewModel.currentUnifiedPushServer() + serverError = null + devTapCount = 0 + } + } Box(modifier.fillMaxSize()) { AndroidView( factory = { context -> @@ -125,6 +152,40 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi Image(painterResource(R.drawable.arrow_back), contentDescription = null) } } + Button( + { + if (showServerDialog) { + return@Button + } + if (devOptionsUnlocked) { + showServerDialog = true + return@Button + } + val nextCount = devTapCount + 1 + if (nextCount >= requiredTapCount) { + devTapCount = 0 + devOptionsUnlocked = true + authenticationViewModel.setDevOptionsUnlocked(true) + Toast.makeText(context, context.getString(R.string.dev_options_unlocked), Toast.LENGTH_SHORT).show() + showServerDialog = true + } else { + devTapCount = nextCount + val remaining = requiredTapCount - nextCount + Toast.makeText( + context, + context.resources.getQuantityString(R.plurals.dev_options_taps_remaining, remaining, remaining), + Toast.LENGTH_SHORT + ).show() + } + }, + colors = ButtonDefaults.textButtonColors(contentColor = Color.White), + modifier = Modifier.align(Alignment.TopEnd).padding(WindowInsets.systemBars.asPaddingValues()) + ) { + Image( + painterResource(R.drawable.menu_settings), + contentDescription = stringResource(R.string.custom_server_content_description) + ) + } val logoPadding by animateDpAsState( if (loginScreenState == LoginScreenState.INITIAL) { 120.dp @@ -231,4 +292,111 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi } } } + if (showServerDialog) { + val dialogContainer = Color(0xFF3B3B3B) + AlertDialog( + onDismissRequest = { showServerDialog = false }, + title = { Text(stringResource(R.string.custom_server_title)) }, + text = { + Column { + Text( + text = stringResource(R.string.dev_options_warning), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 12.dp) + ) + val containerColor = Color(0xFF3B3B3B) + OutlinedTextField( + value = customServerUrl, + onValueChange = { customServerUrl = it }, + label = { Text(stringResource(R.string.custom_server_label), color = Color.White) }, + placeholder = { Text(stringResource(R.string.custom_server_placeholder), color = Color.White.copy(alpha = 0.6f)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.colors( + focusedContainerColor = containerColor, + unfocusedContainerColor = containerColor, + focusedLabelColor = Color.White, + unfocusedLabelColor = Color.White, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + focusedPlaceholderColor = Color.White.copy(alpha = 0.6f), + unfocusedPlaceholderColor = Color.White.copy(alpha = 0.6f) + ) + ) + if (serverError != null) { + Text( + serverError!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = unifiedPushUrl, + onValueChange = { unifiedPushUrl = it }, + label = { Text(stringResource(R.string.custom_up_server_label), color = Color.White) }, + placeholder = { Text(stringResource(R.string.custom_up_server_placeholder), color = Color.White.copy(alpha = 0.6f)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.colors( + focusedContainerColor = containerColor, + unfocusedContainerColor = containerColor, + focusedLabelColor = Color.White, + unfocusedLabelColor = Color.White, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + focusedPlaceholderColor = Color.White.copy(alpha = 0.6f), + unfocusedPlaceholderColor = Color.White.copy(alpha = 0.6f) + ) + ) + } + }, + containerColor = dialogContainer, + tonalElevation = 8.dp, + confirmButton = { + TextButton( + onClick = { + val applied = authenticationViewModel.applyServerOverride(customServerUrl) + if (applied) { + authenticationViewModel.updateUnifiedPushServer(unifiedPushUrl) + serverError = null + showServerDialog = false + } else { + serverError = invalidServerMessage + } + } + ) { + Text(stringResource(R.string.custom_server_apply)) + } + }, + dismissButton = { + Row { + TextButton( + onClick = { + authenticationViewModel.resetServerOverride() + authenticationViewModel.updateUnifiedPushServer(null) + customServerUrl = authenticationViewModel.currentServerSelection() + unifiedPushUrl = authenticationViewModel.currentUnifiedPushServer() + serverError = null + showServerDialog = false + } + ) { + Text(stringResource(R.string.custom_server_reset)) + } + Spacer(modifier = Modifier.width(8.dp)) + TextButton(onClick = { showServerDialog = false }) { + Text(stringResource(android.R.string.cancel)) + } + } + } + ) + } } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/yesterdailies/YesterdailyDialog.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/yesterdailies/YesterdailyDialog.kt index e3f650905..5c091524d 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/yesterdailies/YesterdailyDialog.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/yesterdailies/YesterdailyDialog.kt @@ -9,6 +9,7 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import com.habitrpg.android.habitica.R import com.habitrpg.android.habitica.data.TaskRepository @@ -239,8 +240,17 @@ class YesterdailyDialog private constructor( taskRepository: TaskRepository ) { if (userRepository != null && userId != null) { - MainScope().launchCatching { + val lifecycleOwner = activity as? LifecycleOwner ?: return + lifecycleOwner.lifecycleScope.launchCatching { delay(500.toDuration(DurationUnit.MILLISECONDS)) + val lifecycle = lifecycleOwner.lifecycle + if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED)) { + return@launchCatching + } + val hostActivity = activity + if (hostActivity.isFinishing || hostActivity.isDestroyed) { + return@launchCatching + } if (userRepository.isClosed) { return@launchCatching } @@ -282,7 +292,7 @@ class YesterdailyDialog private constructor( displayedDialog = WeakReference( showDialog( - activity, + hostActivity, userRepository, taskRepository, sortedTasks @@ -307,6 +317,9 @@ class YesterdailyDialog private constructor( dialog.setCanceledOnTouchOutside(false) if (!activity.isFinishing) { dialog.show() + dialog.setOnDismissListener { + displayedDialog = null + } } return dialog } diff --git a/common/src/main/java/com/habitrpg/common/habitica/api/HostConfig.kt b/common/src/main/java/com/habitrpg/common/habitica/api/HostConfig.kt index 51aecb7a9..608c5ea02 100644 --- a/common/src/main/java/com/habitrpg/common/habitica/api/HostConfig.kt +++ b/common/src/main/java/com/habitrpg/common/habitica/api/HostConfig.kt @@ -22,20 +22,19 @@ class HostConfig { constructor(sharedPreferences: SharedPreferences, keyHelper: KeyHelper?, context: Context) { this.port = BuildConfig.PORT - if (BuildConfig.DEBUG) { - this.address = BuildConfig.BASE_URL - if (BuildConfig.TEST_USER_ID.isNotBlank()) { - userID = BuildConfig.TEST_USER_ID - apiKey = BuildConfig.TEST_USER_KEY - return - } - } else { - val address = sharedPreferences.getString("server_url", null) - if (!address.isNullOrEmpty()) { - this.address = address - } else { - this.address = context.getString(com.habitrpg.common.habitica.R.string.base_url) - } + val storedAddress = sharedPreferences.getString("server_url", null)?.takeIf { it.isNotBlank() } + + if (BuildConfig.DEBUG && BuildConfig.TEST_USER_ID.isNotBlank()) { + this.address = storedAddress ?: BuildConfig.BASE_URL + userID = BuildConfig.TEST_USER_ID + apiKey = BuildConfig.TEST_USER_KEY + return + } + + this.address = when { + storedAddress != null -> storedAddress + BuildConfig.DEBUG -> BuildConfig.BASE_URL + else -> context.getString(com.habitrpg.common.habitica.R.string.base_url) } this.userID = sharedPreferences.getString(context.getString(com.habitrpg.common.habitica.R.string.SP_userID), null) ?: "" this.apiKey = loadAPIKey(sharedPreferences, keyHelper)