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)