Add UnifiedPush self-host configuration and custom server login gate

This commit is contained in:
Your Name 2025-09-25 16:45:21 -06:00
parent df561f1fff
commit bae4295ddd
16 changed files with 396 additions and 50 deletions

View file

@ -323,6 +323,13 @@
android:name=".widget.TodosWidgetService" android:name=".widget.TodosWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
<service android:name=".widget.HabitButtonWidgetService"/> <service android:name=".widget.HabitButtonWidgetService"/>
<service
android:name=".helpers.notifications.HabiticaUnifiedPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
<service android:name=".receivers.DeviceCommunicationService" <service android:name=".receivers.DeviceCommunicationService"
android:enabled="true" android:enabled="true"
android:exported="true"> android:exported="true">

View file

@ -177,6 +177,9 @@ dependencies {
implementation(libs.credentials) implementation(libs.credentials)
implementation(libs.credentials.playServicesAuth) implementation(libs.credentials.playServicesAuth)
implementation(libs.googleid) implementation(libs.googleid)
implementation(libs.unifiedpush.connector) {
exclude(group = "com.google.protobuf", module = "protobuf-java")
}
implementation(libs.flexbox) implementation(libs.flexbox)

View file

@ -43,6 +43,15 @@
<string name="preference_push_invited_to_guild">Invited to Guild</string> <string name="preference_push_invited_to_guild">Invited to Guild</string>
<string name="preference_push_your_quest_has_begun">Your Quest has Begun</string> <string name="preference_push_your_quest_has_begun">Your Quest has Begun</string>
<string name="preference_push_invited_to_quest">Invited to Quest</string> <string name="preference_push_invited_to_quest">Invited to Quest</string>
<string name="unified_push_provider_title">UnifiedPush provider</string>
<string name="unified_push_provider_summary">Choose which distributor to use for UnifiedPush notifications.</string>
<string name="unified_push_provider_disabled">UnifiedPush disabled</string>
<string name="unified_push_provider_unavailable">No UnifiedPush distributors found. Install a compatible distributor to enable this option.</string>
<string name="unified_push_test_action">Send test UnifiedPush notification</string>
<string name="unified_push_test_summary">Send a test UnifiedPush notification to confirm your setup.</string>
<string name="unified_push_test_unavailable">Install a UnifiedPush distributor to send test notifications.</string>
<string name="unified_push_test_success">UnifiedPush test notification sent.</string>
<string name="unified_push_test_error">Couldnt send UnifiedPush test notification.</string>
<string name="about_libraries">Libraries</string> <string name="about_libraries">Libraries</string>
<string name="about_habitica_open_source">Habitica is available as open source software on Github</string> <string name="about_habitica_open_source">Habitica is available as open source software on Github</string>

View file

@ -237,6 +237,17 @@
<PreferenceCategory android:title="Push Notifications" <PreferenceCategory android:title="Push Notifications"
android:layout="@layout/preference_category"> android:layout="@layout/preference_category">
<ListPreference
android:key="preference_unified_push_provider"
android:title="@string/unified_push_provider_title"
android:summary="@string/unified_push_provider_summary"
android:layout="@layout/preference_child_summary"
android:dialogTitle="@string/unified_push_provider_title"/>
<Preference
android:key="preference_unified_push_test"
android:title="@string/unified_push_test_action"
android:summary="@string/unified_push_test_summary"
android:layout="@layout/preference_child_summary"/>
<CheckBoxPreference <CheckBoxPreference
android:key="preference_push_you_won_challenge" android:key="preference_push_you_won_challenge"
android:defaultValue="true" android:defaultValue="true"

View file

@ -330,12 +330,14 @@ abstract class HabiticaBaseApplication : Application(), Application.ActivityLife
val lightMode = preferences.getString("theme_mode", "system") val lightMode = preferences.getString("theme_mode", "system")
val launchScreen = preferences.getString("launch_screen", "") val launchScreen = preferences.getString("launch_screen", "")
// set the user and refreshed token in the push manager, so we can remove the push device if (user != null) {
if (deviceToken.isNotEmpty() && user != null) {
pushManager?.setUser(user) pushManager?.setUser(user)
}
if (deviceToken.isNotEmpty()) {
pushManager?.refreshedToken = deviceToken pushManager?.refreshedToken = deviceToken
pushManager?.removePushDeviceUsingStoredToken() pushManager?.removePushDeviceUsingStoredToken()
} }
pushManager?.unregisterUnifiedPushEndpoint()
deleteDatabase(context) deleteDatabase(context)
preferences.edit { preferences.edit {

View file

@ -525,6 +525,11 @@ interface ApiService {
@Body pushDeviceData: Map<String, String> @Body pushDeviceData: Map<String, String>
): HabitResponse<List<Void>> ): HabitResponse<List<Void>>
@POST("user/push-devices/test")
suspend fun sendUnifiedPushTest(
@Body data: Map<String, String>
): HabitResponse<Void>
@DELETE("user/push-devices/{regId}") @DELETE("user/push-devices/{regId}")
suspend fun deletePushDevice( suspend fun deletePushDevice(
@Path("regId") regId: String @Path("regId") regId: String

View file

@ -329,6 +329,11 @@ interface ApiClient {
// Push notifications // Push notifications
suspend fun addPushDevice(pushDeviceData: Map<String, String>): List<Void>? suspend fun addPushDevice(pushDeviceData: Map<String, String>): List<Void>?
suspend fun sendUnifiedPushTest(
regId: String? = null,
message: String? = null
): Void?
suspend fun deletePushDevice(regId: String): List<Void>? suspend fun deletePushDevice(regId: String): List<Void>?
suspend fun getChallengeTasks(challengeId: String): TaskList? suspend fun getChallengeTasks(challengeId: String): TaskList?

View file

@ -918,6 +918,17 @@ class ApiClientImpl(
return process { apiService.addPushDevice(pushDeviceData) } return process { apiService.addPushDevice(pushDeviceData) }
} }
override suspend fun sendUnifiedPushTest(
regId: String?,
message: String?
): Void? {
val body = mutableMapOf<String, String>()
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<Void>? { override suspend fun deletePushDevice(regId: String): List<Void>? {
return process { apiService.deletePushDevice(regId) } return process { apiService.deletePushDevice(regId) }
} }

View file

@ -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<String, String> {
val data = mutableMapOf<String, String>()
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
}
}

View file

@ -13,6 +13,7 @@ import com.habitrpg.android.habitica.helpers.HitType
import com.habitrpg.android.habitica.models.user.User import com.habitrpg.android.habitica.models.user.User
import com.habitrpg.common.habitica.helpers.launchCatching import com.habitrpg.common.habitica.helpers.launchCatching
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import org.unifiedpush.android.connector.UnifiedPush
import java.io.IOException import java.io.IOException
class PushNotificationManager( class PushNotificationManager(
@ -91,22 +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() { private fun addUnifiedPushDeviceIfConfigured() {
val unifiedPushUrl = sharedPreferences.getString(UNIFIED_PUSH_SERVER_KEY, null)?.trim() val unifiedPushUrl = sharedPreferences.getString(UNIFIED_PUSH_ENDPOINT_KEY, null)?.trim()
if (unifiedPushUrl.isNullOrEmpty()) { if (unifiedPushUrl.isNullOrEmpty()) {
return return
} }
enqueueUnifiedPushRegistration(unifiedPushUrl)
}
private fun enqueueUnifiedPushRegistration(endpoint: String, ignoreEmpty: Boolean = false) {
if (endpoint.isEmpty()) {
if (!ignoreEmpty) {
unregisterUnifiedPushEndpoint()
}
return
}
if (this.user == null) { if (this.user == null) {
return return
} }
val pushDeviceData = HashMap<String, String>() val pushDeviceData = HashMap<String, String>()
pushDeviceData["regId"] = unifiedPushUrl pushDeviceData["regId"] = endpoint
pushDeviceData["type"] = "unifiedpush" pushDeviceData["type"] = "unifiedpush"
MainScope().launchCatching { MainScope().launchCatching {
apiClient.addPushDevice(pushDeviceData) apiClient.addPushDevice(pushDeviceData)
} }
} }
private fun enqueueUnifiedPushRemoval(endpoint: String) {
if (endpoint.isEmpty() || this.user == null) {
return
}
MainScope().launchCatching {
apiClient.deletePushDevice(endpoint)
}
}
suspend fun removePushDeviceUsingStoredToken() { suspend fun removePushDeviceUsingStoredToken() {
if (this.refreshedToken.isEmpty() || !userHasPushDevice()) { if (this.refreshedToken.isEmpty() || !userHasPushDevice()) {
return return
@ -157,7 +207,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" const val UNIFIED_PUSH_ENDPOINT_KEY = "unified_push_server_url"
fun displayNotification( fun displayNotification(
remoteMessage: RemoteMessage, remoteMessage: RemoteMessage,

View file

@ -1,11 +1,20 @@
package com.habitrpg.android.habitica.ui.fragments.preferences package com.habitrpg.android.habitica.ui.fragments.preferences
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.CheckBoxPreference import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import com.habitrpg.android.habitica.models.user.User import com.habitrpg.android.habitica.models.user.User
import com.habitrpg.common.habitica.helpers.launchCatching import com.habitrpg.common.habitica.helpers.launchCatching
import dagger.hilt.android.AndroidEntryPoint 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 @AndroidEntryPoint
class PushNotificationsPreferencesFragment : class PushNotificationsPreferencesFragment :
@ -13,10 +22,19 @@ class PushNotificationsPreferencesFragment :
SharedPreferences.OnSharedPreferenceChangeListener { SharedPreferences.OnSharedPreferenceChangeListener {
private var isInitialSet: Boolean = true private var isInitialSet: Boolean = true
private var isSettingUser: Boolean = false 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() { override fun onResume() {
super.onResume() super.onResume()
preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(this) preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
updateUnifiedPushPreference()
} }
override fun onPause() { override fun onPause() {
@ -24,7 +42,24 @@ class PushNotificationsPreferencesFragment :
preferenceScreen.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this) 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?) { 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
}
}
}
} }

View file

@ -83,10 +83,6 @@ class AuthenticationViewModel @Inject constructor(
return sharedPrefs.getString(SERVER_OVERRIDE_KEY, hostConfig.address) ?: hostConfig.address return sharedPrefs.getString(SERVER_OVERRIDE_KEY, hostConfig.address) ?: hostConfig.address
} }
fun currentUnifiedPushServer(): String {
return sharedPrefs.getString(UNIFIED_PUSH_SERVER_KEY, "") ?: ""
}
fun isDevOptionsUnlocked(): Boolean { fun isDevOptionsUnlocked(): Boolean {
return sharedPrefs.getBoolean(DEV_OPTIONS_UNLOCKED_KEY, false) return sharedPrefs.getBoolean(DEV_OPTIONS_UNLOCKED_KEY, false)
} }
@ -109,21 +105,9 @@ class AuthenticationViewModel @Inject constructor(
return true 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() { fun resetServerOverride() {
sharedPrefs.edit { sharedPrefs.edit {
remove(SERVER_OVERRIDE_KEY) remove(SERVER_OVERRIDE_KEY)
remove(UNIFIED_PUSH_SERVER_KEY)
if (!isDevOptionsUnlocked()) { if (!isDevOptionsUnlocked()) {
remove(DEV_OPTIONS_UNLOCKED_KEY) remove(DEV_OPTIONS_UNLOCKED_KEY)
} }
@ -337,7 +321,6 @@ class AuthenticationViewModel @Inject constructor(
companion object { companion object {
private const val SERVER_OVERRIDE_KEY = "server_url" 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" private const val DEV_OPTIONS_UNLOCKED_KEY = "dev_options_unlocked"
} }
} }

View file

@ -112,6 +112,7 @@ constructor(
} }
Analytics.setUserProperty("level", user.stats?.lvl?.toString() ?: "") Analytics.setUserProperty("level", user.stats?.lvl?.toString() ?: "")
pushNotificationManager.setUser(user) pushNotificationManager.setUser(user)
pushNotificationManager.ensureUnifiedPushRegistration()
if (!pushNotificationManager.notificationPermissionEnabled()) { if (!pushNotificationManager.notificationPermissionEnabled()) {
if (sharedPreferences.getBoolean("usePushNotifications", true)) { if (sharedPreferences.getBoolean("usePushNotifications", true)) {
requestNotificationPermission.value = true requestNotificationPermission.value = true

View file

@ -85,7 +85,6 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi
var emailFieldState by remember { mutableStateOf(LoginFieldState.DEFAULT) } var emailFieldState by remember { mutableStateOf(LoginFieldState.DEFAULT) }
var showServerDialog by remember { mutableStateOf(false) } var showServerDialog by remember { mutableStateOf(false) }
var customServerUrl by remember { mutableStateOf(authenticationViewModel.currentServerSelection()) } var customServerUrl by remember { mutableStateOf(authenticationViewModel.currentServerSelection()) }
var unifiedPushUrl by remember { mutableStateOf(authenticationViewModel.currentUnifiedPushServer()) }
var serverError by remember { mutableStateOf<String?>(null) } var serverError by remember { mutableStateOf<String?>(null) }
val invalidServerMessage = stringResource(R.string.custom_server_invalid) val invalidServerMessage = stringResource(R.string.custom_server_invalid)
val context = LocalContext.current val context = LocalContext.current
@ -96,7 +95,6 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi
LaunchedEffect(showServerDialog) { LaunchedEffect(showServerDialog) {
if (showServerDialog) { if (showServerDialog) {
customServerUrl = authenticationViewModel.currentServerSelection() customServerUrl = authenticationViewModel.currentServerSelection()
unifiedPushUrl = authenticationViewModel.currentUnifiedPushServer()
serverError = null serverError = null
devTapCount = 0 devTapCount = 0
} }
@ -336,27 +334,6 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi
) )
} }
Spacer(modifier = Modifier.height(16.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, containerColor = dialogContainer,
@ -366,7 +343,6 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi
onClick = { onClick = {
val applied = authenticationViewModel.applyServerOverride(customServerUrl) val applied = authenticationViewModel.applyServerOverride(customServerUrl)
if (applied) { if (applied) {
authenticationViewModel.updateUnifiedPushServer(unifiedPushUrl)
serverError = null serverError = null
showServerDialog = false showServerDialog = false
} else { } else {
@ -382,9 +358,7 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi
TextButton( TextButton(
onClick = { onClick = {
authenticationViewModel.resetServerOverride() authenticationViewModel.resetServerOverride()
authenticationViewModel.updateUnifiedPushServer(null)
customServerUrl = authenticationViewModel.currentServerSelection() customServerUrl = authenticationViewModel.currentServerSelection()
unifiedPushUrl = authenticationViewModel.currentUnifiedPushServer()
serverError = null serverError = null
showServerDialog = false showServerDialog = false
} }

View file

@ -26,6 +26,9 @@ import com.habitrpg.common.habitica.extensions.isUsingNightModeResources
import com.habitrpg.common.habitica.helpers.ExceptionHandler import com.habitrpg.common.habitica.helpers.ExceptionHandler
import com.habitrpg.common.habitica.helpers.launchCatching import com.habitrpg.common.habitica.helpers.launchCatching
import com.habitrpg.shared.habitica.models.tasks.TaskType 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.MainScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@ -37,9 +40,11 @@ import java.util.Date
import kotlin.math.abs import kotlin.math.abs
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
import kotlin.time.toDuration import kotlin.time.toDuration
import androidx.preference.PreferenceManager
class YesterdailyDialog private constructor( class YesterdailyDialog private constructor(
context: Context, context: Context,
private val userId: String,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val taskRepository: TaskRepository, private val taskRepository: TaskRepository,
private val tasks: List<Task> private val tasks: List<Task>
@ -93,6 +98,7 @@ class YesterdailyDialog private constructor(
MainScope().launch(ExceptionHandler.coroutine()) { MainScope().launch(ExceptionHandler.coroutine()) {
userRepository.runCron(completedTasks) userRepository.runCron(completedTasks)
} }
markShownToday(context, userId)
displayedDialog = null displayedDialog = null
} }
@ -232,6 +238,7 @@ class YesterdailyDialog private constructor(
companion object { companion object {
private var displayedDialog: WeakReference<YesterdailyDialog>? = null private var displayedDialog: WeakReference<YesterdailyDialog>? = null
internal var lastCronRun: Date? = null internal var lastCronRun: Date? = null
private val lastShownByUser: MutableMap<String, Long> = mutableMapOf()
fun showDialogIfNeeded( fun showDialogIfNeeded(
activity: Activity, activity: Activity,
@ -254,6 +261,9 @@ class YesterdailyDialog private constructor(
if (userRepository.isClosed) { if (userRepository.isClosed) {
return@launchCatching return@launchCatching
} }
if (hasShownToday(hostActivity, userId)) {
return@launchCatching
}
val user = userRepository.getUser().firstOrNull() val user = userRepository.getUser().firstOrNull()
if (user?.needsCron != true) { if (user?.needsCron != true) {
return@launchCatching return@launchCatching
@ -289,10 +299,12 @@ class YesterdailyDialog private constructor(
) )
if (sortedTasks?.isNotEmpty() == true) { if (sortedTasks?.isNotEmpty() == true) {
markShownToday(hostActivity, userId)
displayedDialog = displayedDialog =
WeakReference( WeakReference(
showDialog( showDialog(
hostActivity, hostActivity,
userId,
userRepository, userRepository,
taskRepository, taskRepository,
sortedTasks sortedTasks
@ -300,6 +312,7 @@ class YesterdailyDialog private constructor(
) )
} else { } else {
lastCronRun = Date() lastCronRun = Date()
markShownToday(hostActivity, userId)
userRepository.runCron() userRepository.runCron()
} }
} }
@ -308,11 +321,12 @@ class YesterdailyDialog private constructor(
private fun showDialog( private fun showDialog(
activity: Activity, activity: Activity,
userId: String,
userRepository: UserRepository, userRepository: UserRepository,
taskRepository: TaskRepository, taskRepository: TaskRepository,
tasks: List<Task> tasks: List<Task>
): YesterdailyDialog { ): YesterdailyDialog {
val dialog = YesterdailyDialog(activity, userRepository, taskRepository, tasks) val dialog = YesterdailyDialog(activity, userId, userRepository, taskRepository, tasks)
dialog.setCancelable(false) dialog.setCancelable(false)
dialog.setCanceledOnTouchOutside(false) dialog.setCanceledOnTouchOutside(false)
if (!activity.isFinishing) { if (!activity.isFinishing) {
@ -323,5 +337,39 @@ class YesterdailyDialog private constructor(
} }
return dialog 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"
} }
} }

View file

@ -63,6 +63,7 @@ retrofit = "3.0.0"
shimmer = "0.5.0" shimmer = "0.5.0"
swipeRefresh = "1.1.0" swipeRefresh = "1.1.0"
turbine = "1.2.1" turbine = "1.2.1"
unifiedpush = "3.0.10"
wear = "1.3.0" wear = "1.3.0"
wearInput = "1.1.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" } 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" } text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts", version.ref = "compose" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } 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" } ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" }
viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
wear = { module = "androidx.wear:wear", version.ref = "wear" } wear = { module = "androidx.wear:wear", version.ref = "wear" }