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="one">%d Item pending</item>
<item quantity="other">%d Items pending</item> <item quantity="other">%d Items pending</item>
</plurals> </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> </resources>

View file

@ -76,14 +76,19 @@ object Analytics {
} }
fun initialize(context: Context) { fun initialize(context: Context) {
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 =
Amplitude( Amplitude(
Configuration( Configuration(
context.getString(R.string.amplitude_app_id), amplitudeAppId,
context, context,
optOut = true, optOut = true,
) )
) )
}
firebase = FirebaseAnalytics.getInstance(context) firebase = FirebaseAnalytics.getInstance(context)
} }

View file

@ -76,6 +76,7 @@ class PushNotificationManager(
// catchy catch // catchy catch
} }
} }
addUnifiedPushDeviceIfConfigured()
} }
private fun addRefreshToken() { 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() { suspend fun removePushDeviceUsingStoredToken() {
if (this.refreshedToken.isEmpty() || !userHasPushDevice()) { if (this.refreshedToken.isEmpty() || !userHasPushDevice()) {
return return
@ -140,6 +157,7 @@ class PushNotificationManager(
const val CONTENT_RELEASE_NOTIFICATION_KEY = "contentRelease" const val CONTENT_RELEASE_NOTIFICATION_KEY = "contentRelease"
const val G1G1_PROMO_KEY = "g1g1Promo" const val G1G1_PROMO_KEY = "g1g1Promo"
const val DEVICE_TOKEN_PREFERENCE_KEY = "device-token-preference" const val DEVICE_TOKEN_PREFERENCE_KEY = "device-token-preference"
private const val UNIFIED_PUSH_SERVER_KEY = "unified_push_server_url"
fun displayNotification( fun displayNotification(
remoteMessage: RemoteMessage, remoteMessage: RemoteMessage,

View file

@ -71,7 +71,7 @@ class OnboardingActivity: BaseActivity() {
val authenticationViewModel: AuthenticationViewModel by viewModels() val authenticationViewModel: AuthenticationViewModel by viewModels()
val currentStep = mutableStateOf(OnboardingSteps.SETUP) val currentStep = mutableStateOf(OnboardingSteps.LOGIN)
@Inject @Inject
lateinit var configManager: AppConfigManager lateinit var configManager: AppConfigManager
@ -91,6 +91,11 @@ class OnboardingActivity: BaseActivity() {
// Set default values to avoid null-responses when requesting unedited settings // Set default values to avoid null-responses when requesting unedited settings
PreferenceManager.setDefaultValues(this, R.xml.preferences_fragment, false) PreferenceManager.setDefaultValues(this, R.xml.preferences_fragment, false)
if (authenticationViewModel.hostConfig.hasAuthentication()) {
startMainActivity()
return
}
binding.composeView.setContent { binding.composeView.setContent {
val step by currentStep val step by currentStep
HabiticaTheme { HabiticaTheme {

View file

@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@HiltViewModel @HiltViewModel
class AuthenticationViewModel @Inject constructor( class AuthenticationViewModel @Inject constructor(
@ -78,6 +79,66 @@ class AuthenticationViewModel @Inject constructor(
private var _usernameIssues = MutableStateFlow<String?>(null) private var _usernameIssues = MutableStateFlow<String?>(null)
val usernameIssues: Flow<String?> = _usernameIssues 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( fun validateInputs(
username: String, username: String,
password: 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 package com.habitrpg.android.habitica.ui.views.login
import android.util.Patterns import android.util.Patterns
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.EaseInOut import androidx.compose.animation.core.EaseInOut
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
@ -13,6 +14,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues 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.padding
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -74,6 +83,24 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi
var passwordFieldState by remember { mutableStateOf(LoginFieldState.DEFAULT) } var passwordFieldState by remember { mutableStateOf(LoginFieldState.DEFAULT) }
var email by authenticationViewModel.email var email by authenticationViewModel.email
var emailFieldState by remember { mutableStateOf(LoginFieldState.DEFAULT) } 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()) { Box(modifier.fillMaxSize()) {
AndroidView( AndroidView(
factory = { context -> factory = { context ->
@ -125,6 +152,40 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi
Image(painterResource(R.drawable.arrow_back), contentDescription = null) 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( val logoPadding by animateDpAsState(
if (loginScreenState == LoginScreenState.INITIAL) { if (loginScreenState == LoginScreenState.INITIAL) {
120.dp 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.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.habitrpg.android.habitica.R import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.data.TaskRepository import com.habitrpg.android.habitica.data.TaskRepository
@ -239,8 +240,17 @@ class YesterdailyDialog private constructor(
taskRepository: TaskRepository taskRepository: TaskRepository
) { ) {
if (userRepository != null && userId != null) { if (userRepository != null && userId != null) {
MainScope().launchCatching { val lifecycleOwner = activity as? LifecycleOwner ?: return
lifecycleOwner.lifecycleScope.launchCatching {
delay(500.toDuration(DurationUnit.MILLISECONDS)) 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) { if (userRepository.isClosed) {
return@launchCatching return@launchCatching
} }
@ -282,7 +292,7 @@ class YesterdailyDialog private constructor(
displayedDialog = displayedDialog =
WeakReference( WeakReference(
showDialog( showDialog(
activity, hostActivity,
userRepository, userRepository,
taskRepository, taskRepository,
sortedTasks sortedTasks
@ -307,6 +317,9 @@ class YesterdailyDialog private constructor(
dialog.setCanceledOnTouchOutside(false) dialog.setCanceledOnTouchOutside(false)
if (!activity.isFinishing) { if (!activity.isFinishing) {
dialog.show() dialog.show()
dialog.setOnDismissListener {
displayedDialog = null
}
} }
return dialog return dialog
} }

View file

@ -22,20 +22,19 @@ class HostConfig {
constructor(sharedPreferences: SharedPreferences, keyHelper: KeyHelper?, context: Context) { constructor(sharedPreferences: SharedPreferences, keyHelper: KeyHelper?, context: Context) {
this.port = BuildConfig.PORT this.port = BuildConfig.PORT
if (BuildConfig.DEBUG) { val storedAddress = sharedPreferences.getString("server_url", null)?.takeIf { it.isNotBlank() }
this.address = BuildConfig.BASE_URL
if (BuildConfig.TEST_USER_ID.isNotBlank()) { if (BuildConfig.DEBUG && BuildConfig.TEST_USER_ID.isNotBlank()) {
this.address = storedAddress ?: BuildConfig.BASE_URL
userID = BuildConfig.TEST_USER_ID userID = BuildConfig.TEST_USER_ID
apiKey = BuildConfig.TEST_USER_KEY apiKey = BuildConfig.TEST_USER_KEY
return return
} }
} else {
val address = sharedPreferences.getString("server_url", null) this.address = when {
if (!address.isNullOrEmpty()) { storedAddress != null -> storedAddress
this.address = address BuildConfig.DEBUG -> BuildConfig.BASE_URL
} else { else -> context.getString(com.habitrpg.common.habitica.R.string.base_url)
this.address = 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.userID = sharedPreferences.getString(context.getString(com.habitrpg.common.habitica.R.string.SP_userID), null) ?: ""
this.apiKey = loadAPIKey(sharedPreferences, keyHelper) this.apiKey = loadAPIKey(sharedPreferences, keyHelper)