add self-host options to login

This commit is contained in:
Your Name 2025-09-17 08:20:08 -06:00
parent 2335bff814
commit df561f1fff
8 changed files with 314 additions and 24 deletions

View file

@ -1641,4 +1641,19 @@
<item quantity="one">%d Item pending</item>
<item quantity="other">%d Items pending</item>
</plurals>
<string name="custom_server_content_description">Server options</string>
<string name="custom_server_title">Server configuration</string>
<string name="custom_server_label">Server URL</string>
<string name="custom_server_placeholder">https://habitica.com</string>
<string name="custom_server_apply">Apply</string>
<string name="custom_server_reset">Reset to default</string>
<string name="custom_server_invalid">Couldnt understand that server address. Include http:// or https:// and a host name.</string>
<string name="dev_options_warning">Warning: Only use these developer settings if you know what youre doing.</string>
<plurals name="dev_options_taps_remaining">
<item quantity="one">Developer options unlock in %d tap.</item>
<item quantity="other">Developer options unlock in %d taps.</item>
</plurals>
<string name="dev_options_unlocked">Developer options unlocked.</string>
<string name="custom_up_server_label">UnifiedPush server URL</string>
<string name="custom_up_server_placeholder">https://example.com</string>
</resources>

View file

@ -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)
}

View file

@ -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<String, String>()
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,

View file

@ -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 {

View file

@ -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<String?>(null)
val usernameIssues: Flow<String?> = _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"
}
}

View file

@ -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<String?>(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))
}
}
}
)
}
}

View file

@ -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
}

View file

@ -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)