diff --git a/Habitica/AndroidManifest.xml b/Habitica/AndroidManifest.xml
index 66e7a6687..debde8666 100644
--- a/Habitica/AndroidManifest.xml
+++ b/Habitica/AndroidManifest.xml
@@ -323,6 +323,13 @@
android:name=".widget.TodosWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />
+
+
+
+
+
diff --git a/Habitica/build.gradle.kts b/Habitica/build.gradle.kts
index 8c1d34b31..2745e565d 100644
--- a/Habitica/build.gradle.kts
+++ b/Habitica/build.gradle.kts
@@ -177,6 +177,9 @@ dependencies {
implementation(libs.credentials)
implementation(libs.credentials.playServicesAuth)
implementation(libs.googleid)
+ implementation(libs.unifiedpush.connector) {
+ exclude(group = "com.google.protobuf", module = "protobuf-java")
+ }
implementation(libs.flexbox)
diff --git a/Habitica/res/values/strings.xml b/Habitica/res/values/strings.xml
index b196b73d7..23d702e86 100644
--- a/Habitica/res/values/strings.xml
+++ b/Habitica/res/values/strings.xml
@@ -43,6 +43,15 @@
Invited to Guild
Your Quest has Begun
Invited to Quest
+ UnifiedPush provider
+ Choose which distributor to use for UnifiedPush notifications.
+ UnifiedPush disabled
+ No UnifiedPush distributors found. Install a compatible distributor to enable this option.
+ Send test UnifiedPush notification
+ Send a test UnifiedPush notification to confirm your setup.
+ Install a UnifiedPush distributor to send test notifications.
+ UnifiedPush test notification sent.
+ Couldn’t send UnifiedPush test notification.
Libraries
Habitica is available as open source software on Github
@@ -1641,4 +1650,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/res/xml/preferences_fragment.xml b/Habitica/res/xml/preferences_fragment.xml
index b59f8d0cb..11ce93092 100644
--- a/Habitica/res/xml/preferences_fragment.xml
+++ b/Habitica/res/xml/preferences_fragment.xml
@@ -237,6 +237,17 @@
+
+
): HabitResponse>
+ @POST("user/push-devices/test")
+ suspend fun sendUnifiedPushTest(
+ @Body data: Map
+ ): HabitResponse
+
@DELETE("user/push-devices/{regId}")
suspend fun deletePushDevice(
@Path("regId") regId: String
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt
index e59aa95c5..1bb224ada 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt
@@ -329,6 +329,11 @@ interface ApiClient {
// Push notifications
suspend fun addPushDevice(pushDeviceData: Map): List?
+ suspend fun sendUnifiedPushTest(
+ regId: String? = null,
+ message: String? = null
+ ): Void?
+
suspend fun deletePushDevice(regId: String): List?
suspend fun getChallengeTasks(challengeId: String): TaskList?
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt
index c767121ae..f76211e71 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt
@@ -918,6 +918,17 @@ class ApiClientImpl(
return process { apiService.addPushDevice(pushDeviceData) }
}
+ override suspend fun sendUnifiedPushTest(
+ regId: String?,
+ message: String?
+ ): Void? {
+ val body = mutableMapOf()
+ body["type"] = "unifiedpush"
+ regId?.let { body["regId"] = it }
+ message?.takeIf { it.isNotBlank() }?.let { body["message"] = it }
+ return process { apiService.sendUnifiedPushTest(body) }
+ }
+
override suspend fun deletePushDevice(regId: String): List? {
return process { apiService.deletePushDevice(regId) }
}
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/HabiticaUnifiedPushService.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/notifications/HabiticaUnifiedPushService.kt
new file mode 100644
index 000000000..f9854c017
--- /dev/null
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/notifications/HabiticaUnifiedPushService.kt
@@ -0,0 +1,114 @@
+package com.habitrpg.android.habitica.helpers.notifications
+
+import android.util.Log
+import com.google.firebase.messaging.RemoteMessage
+import com.habitrpg.android.habitica.R
+import dagger.hilt.android.AndroidEntryPoint
+import java.nio.charset.Charset
+import java.util.UUID
+import javax.inject.Inject
+import org.json.JSONException
+import org.json.JSONObject
+import org.unifiedpush.android.connector.FailedReason
+import org.unifiedpush.android.connector.PushService
+import org.unifiedpush.android.connector.data.PushEndpoint
+import org.unifiedpush.android.connector.data.PushMessage
+
+@AndroidEntryPoint
+class HabiticaUnifiedPushService : PushService() {
+ @Inject
+ lateinit var pushNotificationManager: PushNotificationManager
+
+ override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
+ pushNotificationManager.registerUnifiedPushEndpoint(endpoint.url)
+ Log.d(TAG, "UnifiedPush endpoint updated for instance $instance")
+ }
+
+ override fun onMessage(message: PushMessage, instance: String) {
+ val payload = buildDataMap(message)
+ if (payload.isEmpty()) {
+ Log.w(TAG, "UnifiedPush message for instance $instance contained no usable data")
+ return
+ }
+
+ val title = payload["title"]
+ val body = payload["body"] ?: payload["message"]
+ val remoteMessageBuilder = RemoteMessage.Builder("${applicationContext.packageName}.unifiedpush")
+ .setMessageId(UUID.randomUUID().toString())
+
+ payload.forEach { (key, value) ->
+ if (!value.isNullOrEmpty()) {
+ remoteMessageBuilder.addData(key, value)
+ }
+ }
+
+ val remoteMessage = remoteMessageBuilder.build()
+ PushNotificationManager.displayNotification(remoteMessage, applicationContext, pushNotificationManager)
+ Log.d(TAG, "UnifiedPush message delivered for instance $instance (title=${title.orEmpty()}, identifier=${payload["identifier"].orEmpty()})")
+ }
+
+ override fun onRegistrationFailed(reason: FailedReason, instance: String) {
+ pushNotificationManager.unregisterUnifiedPushEndpoint()
+ Log.w(TAG, "UnifiedPush registration failed for instance $instance: $reason")
+ }
+
+ override fun onUnregistered(instance: String) {
+ pushNotificationManager.unregisterUnifiedPushEndpoint()
+ Log.d(TAG, "UnifiedPush unregistered for instance $instance")
+ }
+
+ companion object {
+ private const val TAG = "HabiticaUnifiedPush"
+ }
+
+ private fun buildDataMap(pushMessage: PushMessage): MutableMap {
+ val data = mutableMapOf()
+ val raw = try {
+ String(pushMessage.content, Charset.forName("UTF-8"))
+ } catch (e: Exception) {
+ Log.w(TAG, "Unable to decode UnifiedPush payload", e)
+ return data
+ }
+
+ if (raw.isBlank()) {
+ return data
+ }
+
+ try {
+ val json = JSONObject(raw)
+ json.optString("identifier")?.takeIf { it.isNotBlank() }?.let { data["identifier"] = it }
+ json.optString("title")?.takeIf { it.isNotBlank() }?.let { data["title"] = it }
+ json.optString("body")?.takeIf { it.isNotBlank() }?.let { data["body"] = it }
+ json.optString("message")?.takeIf { it.isNotBlank() }?.let {
+ data["message"] = it
+ if (!data.containsKey("body")) {
+ data["body"] = it
+ }
+ }
+ json.optString("priority")?.takeIf { it.isNotBlank() }?.let { data["priority"] = it }
+
+ val payload = json.optJSONObject("payload")
+ payload?.let {
+ val keys = it.keys()
+ while (keys.hasNext()) {
+ val key = keys.next()
+ val value = it.opt(key)
+ if (value != null && value.toString().isNotEmpty()) {
+ data[key] = value.toString()
+ }
+ }
+ }
+ } catch (jsonError: JSONException) {
+ // Treat the raw message as body text when JSON parsing fails
+ data["body"] = raw
+ data["message"] = raw
+ Log.w(TAG, "UnifiedPush payload not JSON, using raw message", jsonError)
+ }
+
+ if (!data.containsKey("title")) {
+ data["title"] = applicationContext.getString(R.string.app_name)
+ }
+
+ return data
+ }
+}
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..82c65dce1 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
@@ -13,6 +13,7 @@ import com.habitrpg.android.habitica.helpers.HitType
import com.habitrpg.android.habitica.models.user.User
import com.habitrpg.common.habitica.helpers.launchCatching
import kotlinx.coroutines.MainScope
+import org.unifiedpush.android.connector.UnifiedPush
import java.io.IOException
class PushNotificationManager(
@@ -76,6 +77,7 @@ class PushNotificationManager(
// catchy catch
}
}
+ addUnifiedPushDeviceIfConfigured()
}
private fun addRefreshToken() {
@@ -90,6 +92,71 @@ class PushNotificationManager(
}
}
+ fun registerUnifiedPushEndpoint(endpoint: String) {
+ val sanitized = endpoint.trim()
+ val previous = sharedPreferences.getString(UNIFIED_PUSH_ENDPOINT_KEY, null)?.trim()
+ if (!previous.isNullOrEmpty() && previous != sanitized) {
+ enqueueUnifiedPushRemoval(previous)
+ }
+ sharedPreferences.edit {
+ putString(UNIFIED_PUSH_ENDPOINT_KEY, sanitized)
+ }
+ enqueueUnifiedPushRegistration(sanitized)
+ }
+
+ fun unregisterUnifiedPushEndpoint() {
+ val existing = sharedPreferences.getString(UNIFIED_PUSH_ENDPOINT_KEY, null)?.trim()
+ if (!existing.isNullOrEmpty()) {
+ enqueueUnifiedPushRemoval(existing)
+ }
+ sharedPreferences.edit {
+ remove(UNIFIED_PUSH_ENDPOINT_KEY)
+ }
+ }
+
+ fun ensureUnifiedPushRegistration() {
+ UnifiedPush.register(context)
+ enqueueUnifiedPushRegistration(
+ sharedPreferences.getString(UNIFIED_PUSH_ENDPOINT_KEY, null)?.trim().orEmpty(),
+ ignoreEmpty = true
+ )
+ }
+
+ private fun addUnifiedPushDeviceIfConfigured() {
+ val unifiedPushUrl = sharedPreferences.getString(UNIFIED_PUSH_ENDPOINT_KEY, null)?.trim()
+ if (unifiedPushUrl.isNullOrEmpty()) {
+ return
+ }
+ enqueueUnifiedPushRegistration(unifiedPushUrl)
+ }
+
+ private fun enqueueUnifiedPushRegistration(endpoint: String, ignoreEmpty: Boolean = false) {
+ if (endpoint.isEmpty()) {
+ if (!ignoreEmpty) {
+ unregisterUnifiedPushEndpoint()
+ }
+ return
+ }
+ if (this.user == null) {
+ return
+ }
+ val pushDeviceData = HashMap()
+ pushDeviceData["regId"] = endpoint
+ pushDeviceData["type"] = "unifiedpush"
+ MainScope().launchCatching {
+ apiClient.addPushDevice(pushDeviceData)
+ }
+ }
+
+ private fun enqueueUnifiedPushRemoval(endpoint: String) {
+ if (endpoint.isEmpty() || this.user == null) {
+ return
+ }
+ MainScope().launchCatching {
+ apiClient.deletePushDevice(endpoint)
+ }
+ }
+
suspend fun removePushDeviceUsingStoredToken() {
if (this.refreshedToken.isEmpty() || !userHasPushDevice()) {
return
@@ -140,6 +207,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"
+ const val UNIFIED_PUSH_ENDPOINT_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/fragments/preferences/PushNotificationsPreferencesFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/PushNotificationsPreferencesFragment.kt
index cea30c931..aa7f3b841 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/PushNotificationsPreferencesFragment.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/PushNotificationsPreferencesFragment.kt
@@ -1,11 +1,20 @@
package com.habitrpg.android.habitica.ui.fragments.preferences
import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import androidx.preference.CheckBoxPreference
+import androidx.preference.ListPreference
+import androidx.preference.Preference
import com.habitrpg.android.habitica.models.user.User
import com.habitrpg.common.habitica.helpers.launchCatching
import dagger.hilt.android.AndroidEntryPoint
+import org.unifiedpush.android.connector.UnifiedPush
+import javax.inject.Inject
+import com.habitrpg.android.habitica.R
+import com.habitrpg.android.habitica.helpers.notifications.PushNotificationManager
+import com.habitrpg.android.habitica.data.ApiClient
@AndroidEntryPoint
class PushNotificationsPreferencesFragment :
@@ -13,10 +22,19 @@ class PushNotificationsPreferencesFragment :
SharedPreferences.OnSharedPreferenceChangeListener {
private var isInitialSet: Boolean = true
private var isSettingUser: Boolean = false
+ private var unifiedPushPreference: ListPreference? = null
+ private var unifiedPushTestPreference: Preference? = null
+
+ @Inject
+ lateinit var pushNotificationManager: PushNotificationManager
+
+ @Inject
+ lateinit var apiClient: ApiClient
override fun onResume() {
super.onResume()
preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
+ updateUnifiedPushPreference()
}
override fun onPause() {
@@ -24,7 +42,24 @@ class PushNotificationsPreferencesFragment :
preferenceScreen.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this)
}
- override fun setupPreferences() { // no-on
+ override fun setupPreferences() {
+ unifiedPushPreference = findPreference("preference_unified_push_provider") as? ListPreference
+ unifiedPushPreference?.setOnPreferenceChangeListener { preference, newValue ->
+ val packageName = (newValue as? String).orEmpty()
+ handleUnifiedPushSelection(packageName)
+ val listPreference = preference as? ListPreference
+ val index = listPreference?.entryValues?.indexOf(packageName) ?: -1
+ if (index >= 0 && listPreference?.entries?.size.orZero() > index) {
+ listPreference?.summary = listPreference?.entries?.get(index)
+ }
+ true
+ }
+ unifiedPushTestPreference = findPreference("preference_unified_push_test")
+ unifiedPushTestPreference?.setOnPreferenceClickListener {
+ triggerUnifiedPushTest()
+ true
+ }
+ updateUnifiedPushPreference()
}
override fun setUser(user: User?) {
@@ -128,4 +163,90 @@ class PushNotificationsPreferencesFragment :
}
}
}
+
+ private fun handleUnifiedPushSelection(packageName: String) {
+ val context = context ?: return
+ if (packageName.isEmpty()) {
+ UnifiedPush.removeDistributor(context)
+ pushNotificationManager.unregisterUnifiedPushEndpoint()
+ } else {
+ UnifiedPush.saveDistributor(context, packageName)
+ UnifiedPush.register(context)
+ pushNotificationManager.ensureUnifiedPushRegistration()
+ }
+ updateUnifiedPushPreference()
+ }
+
+ private fun updateUnifiedPushPreference() {
+ val preference = unifiedPushPreference ?: return
+ val context = context ?: return
+ val distributors = UnifiedPush.getDistributors(context)
+ if (distributors.isEmpty()) {
+ preference.isEnabled = false
+ preference.summary = getString(R.string.unified_push_provider_unavailable)
+ preference.entries = arrayOf(getString(R.string.unified_push_provider_disabled))
+ preference.entryValues = arrayOf("")
+ preference.value = ""
+ unifiedPushTestPreference?.apply {
+ isVisible = false
+ isEnabled = false
+ summary = getString(R.string.unified_push_test_unavailable)
+ }
+ return
+ }
+
+ preference.isEnabled = true
+ val pm = context.packageManager
+ val entries = mutableListOf(getString(R.string.unified_push_provider_disabled))
+ val values = mutableListOf("")
+
+ distributors.distinct().forEach { packageName ->
+ val label = resolveAppLabel(pm, packageName)
+ entries.add(label)
+ values.add(packageName)
+ }
+
+ preference.entries = entries.toTypedArray()
+ preference.entryValues = values.toTypedArray()
+
+ val savedDistributor = UnifiedPush.getSavedDistributor(context)
+ val resolvedValue = savedDistributor?.takeIf { values.contains(it) } ?: ""
+ preference.value = resolvedValue
+
+ val selectedIndex = values.indexOf(resolvedValue).takeIf { it >= 0 } ?: 0
+ preference.summary = entries[selectedIndex]
+
+ unifiedPushTestPreference?.apply {
+ isVisible = true
+ isEnabled = true
+ summary = getString(R.string.unified_push_test_summary)
+ }
+ }
+
+ private fun resolveAppLabel(pm: PackageManager, packageName: String): String {
+ return runCatching {
+ val applicationInfo = pm.getApplicationInfo(packageName, 0)
+ pm.getApplicationLabel(applicationInfo).toString()
+ }.getOrDefault(packageName)
+ }
+
+ private fun Int?.orZero(): Int = this ?: 0
+
+ private fun triggerUnifiedPushTest() {
+ val context = context ?: return
+ val preference = unifiedPushTestPreference ?: return
+ preference.isEnabled = false
+ lifecycleScope.launchCatching({
+ preference.isEnabled = true
+ Toast.makeText(context, getString(R.string.unified_push_test_error), Toast.LENGTH_LONG).show()
+ }) {
+ try {
+ pushNotificationManager.ensureUnifiedPushRegistration()
+ apiClient.sendUnifiedPushTest()
+ Toast.makeText(context, getString(R.string.unified_push_test_success), Toast.LENGTH_SHORT).show()
+ } finally {
+ preference.isEnabled = true
+ }
+ }
+ }
}
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..a1b034543 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,50 @@ 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 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 resetServerOverride() {
+ sharedPrefs.edit {
+ remove(SERVER_OVERRIDE_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 +318,9 @@ class AuthenticationViewModel @Inject constructor(
}
}
}
+
+ companion object {
+ private const val SERVER_OVERRIDE_KEY = "server_url"
+ private const val DEV_OPTIONS_UNLOCKED_KEY = "dev_options_unlocked"
+ }
}
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/MainActivityViewModel.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/MainActivityViewModel.kt
index 2aacb878c..58ab72e5f 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/MainActivityViewModel.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/MainActivityViewModel.kt
@@ -112,6 +112,7 @@ constructor(
}
Analytics.setUserProperty("level", user.stats?.lvl?.toString() ?: "")
pushNotificationManager.setUser(user)
+ pushNotificationManager.ensureUnifiedPushRegistration()
if (!pushNotificationManager.notificationPermissionEnabled()) {
if (sharedPreferences.getBoolean("usePushNotifications", true)) {
requestNotificationPermission.value = true
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..af4a7dc5f 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,22 @@ 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 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()
+ serverError = null
+ devTapCount = 0
+ }
+ }
Box(modifier.fillMaxSize()) {
AndroidView(
factory = { context ->
@@ -125,6 +150,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 +290,87 @@ 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))
+ }
+ },
+ containerColor = dialogContainer,
+ tonalElevation = 8.dp,
+ confirmButton = {
+ TextButton(
+ onClick = {
+ val applied = authenticationViewModel.applyServerOverride(customServerUrl)
+ if (applied) {
+ serverError = null
+ showServerDialog = false
+ } else {
+ serverError = invalidServerMessage
+ }
+ }
+ ) {
+ Text(stringResource(R.string.custom_server_apply))
+ }
+ },
+ dismissButton = {
+ Row {
+ TextButton(
+ onClick = {
+ authenticationViewModel.resetServerOverride()
+ customServerUrl = authenticationViewModel.currentServerSelection()
+ 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..9008b25e4 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
@@ -25,6 +26,9 @@ import com.habitrpg.common.habitica.extensions.isUsingNightModeResources
import com.habitrpg.common.habitica.helpers.ExceptionHandler
import com.habitrpg.common.habitica.helpers.launchCatching
import com.habitrpg.shared.habitica.models.tasks.TaskType
+import java.time.Instant
+import java.time.LocalDate
+import java.time.ZoneId
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.firstOrNull
@@ -36,9 +40,11 @@ import java.util.Date
import kotlin.math.abs
import kotlin.time.DurationUnit
import kotlin.time.toDuration
+import androidx.preference.PreferenceManager
class YesterdailyDialog private constructor(
context: Context,
+ private val userId: String,
private val userRepository: UserRepository,
private val taskRepository: TaskRepository,
private val tasks: List
@@ -92,6 +98,7 @@ class YesterdailyDialog private constructor(
MainScope().launch(ExceptionHandler.coroutine()) {
userRepository.runCron(completedTasks)
}
+ markShownToday(context, userId)
displayedDialog = null
}
@@ -231,6 +238,7 @@ class YesterdailyDialog private constructor(
companion object {
private var displayedDialog: WeakReference? = null
internal var lastCronRun: Date? = null
+ private val lastShownByUser: MutableMap = mutableMapOf()
fun showDialogIfNeeded(
activity: Activity,
@@ -239,11 +247,23 @@ 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
}
+ if (hasShownToday(hostActivity, userId)) {
+ return@launchCatching
+ }
val user = userRepository.getUser().firstOrNull()
if (user?.needsCron != true) {
return@launchCatching
@@ -279,10 +299,12 @@ class YesterdailyDialog private constructor(
)
if (sortedTasks?.isNotEmpty() == true) {
+ markShownToday(hostActivity, userId)
displayedDialog =
WeakReference(
showDialog(
- activity,
+ hostActivity,
+ userId,
userRepository,
taskRepository,
sortedTasks
@@ -290,6 +312,7 @@ class YesterdailyDialog private constructor(
)
} else {
lastCronRun = Date()
+ markShownToday(hostActivity, userId)
userRepository.runCron()
}
}
@@ -298,17 +321,55 @@ class YesterdailyDialog private constructor(
private fun showDialog(
activity: Activity,
+ userId: String,
userRepository: UserRepository,
taskRepository: TaskRepository,
tasks: List
): YesterdailyDialog {
- val dialog = YesterdailyDialog(activity, userRepository, taskRepository, tasks)
+ val dialog = YesterdailyDialog(activity, userId, userRepository, taskRepository, tasks)
dialog.setCancelable(false)
dialog.setCanceledOnTouchOutside(false)
if (!activity.isFinishing) {
dialog.show()
+ dialog.setOnDismissListener {
+ displayedDialog = null
+ }
}
return dialog
}
+
+ private fun hasShownToday(context: Context, userId: String): Boolean {
+ val lastShown = lastShownByUser[userId] ?: loadLastShown(context, userId)
+ if (lastShown == 0L) {
+ return false
+ }
+ val lastShownDate = Instant.ofEpochMilli(lastShown).atZone(ZoneId.systemDefault()).toLocalDate()
+ val today = LocalDate.now()
+ val hasShown = lastShownDate == today
+ if (hasShown) {
+ lastShownByUser[userId] = lastShown
+ }
+ return hasShown
+ }
+
+ private fun markShownToday(context: Context, userId: String) {
+ val now = System.currentTimeMillis()
+ lastShownByUser[userId] = now
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putLong(prefKeyForUser(userId), now)
+ .apply()
+ }
+
+ private fun loadLastShown(context: Context, userId: String): Long {
+ val stored = PreferenceManager.getDefaultSharedPreferences(context)
+ .getLong(prefKeyForUser(userId), 0L)
+ if (stored != 0L) {
+ lastShownByUser[userId] = stored
+ }
+ return stored
+ }
+
+ private fun prefKeyForUser(userId: String): String = "yesterdaily_last_shown_$userId"
}
}
diff --git a/README.md b/README.md
index e365ded06..f8736d671 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,13 @@ See the project's Releases page for a list of versions with their changelogs.
##### [View Releases](https://github.com/HabitRPG/habitrpg-android/releases)
+If you are deploying the companion self-hosted server (`https://github.com/sudoxnym/habitica-self-host`), use the APKs named below so the login screen can target that backend out of the box.
+
+Self-hosted builds published from this repository include two APKs in each release:
+
+- `habitica-self-host-debug.apk` – debuggable build with developer tooling enabled
+- `habitica-self-host-release.apk` – optimized release build ready for deployment
+
If you Watch this repository, GitHub will send you an email every time we publish an update.
## Contributing
@@ -61,6 +68,8 @@ We use Kotlin and follow the code style based on the [Android Kotlin Style Guide
Note: this is the default production `habitica.properties` file for habitica.com. If you want to use a local Habitica server, please modify the values in the properties file accordingly.
+ When running a self-hosted build you can now switch servers directly from the login screen—ideal for pairing with [`habitica-self-host`](https://github.com/sudoxnym/habitica-self-host). Tap the gear icon in the upper-right corner seven times to unlock the developer options dialog, enter your custom base URL, and (optionally) enable UnifiedPush with the distributor installed on the device.
+
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)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e0c74b4fa..e347280b5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -63,6 +63,7 @@ retrofit = "3.0.0"
shimmer = "0.5.0"
swipeRefresh = "1.1.0"
turbine = "1.2.1"
+unifiedpush = "3.0.10"
wear = "1.3.0"
wearInput = "1.1.0"
@@ -153,6 +154,7 @@ test-rules = { group = "androidx.test", name = "rules", version.ref = "androidTe
test-runner = { group = "androidx.test", name = "runner", version.ref = "androidTestRunner" }
text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts", version.ref = "compose" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
+unifiedpush-connector = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" }
viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
wear = { module = "androidx.wear:wear", version.ref = "wear" }
diff --git a/release-artifacts/Habitica-prod-release.apk b/release-artifacts/Habitica-prod-release.apk
new file mode 100644
index 000000000..68a19a331
Binary files /dev/null and b/release-artifacts/Habitica-prod-release.apk differ
diff --git a/release-artifacts/habitica-self-host-debug.apk b/release-artifacts/habitica-self-host-debug.apk
new file mode 100644
index 000000000..c823f9cf0
Binary files /dev/null and b/release-artifacts/habitica-self-host-debug.apk differ
diff --git a/release-artifacts/habitica-self-host-release.apk b/release-artifacts/habitica-self-host-release.apk
new file mode 100644
index 000000000..68a19a331
Binary files /dev/null and b/release-artifacts/habitica-self-host-release.apk differ