From bae4295ddd2202c7fd1f7d1e0f06dd154171919e Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 25 Sep 2025 16:45:21 -0600 Subject: [PATCH] Add UnifiedPush self-host configuration and custom server login gate --- Habitica/AndroidManifest.xml | 7 + Habitica/build.gradle.kts | 3 + Habitica/res/values/strings.xml | 9 ++ Habitica/res/xml/preferences_fragment.xml | 11 ++ .../habitica/HabiticaBaseApplication.kt | 6 +- .../android/habitica/api/ApiService.kt | 5 + .../android/habitica/data/ApiClient.kt | 5 + .../data/implementation/ApiClientImpl.kt | 11 ++ .../HabiticaUnifiedPushService.kt | 114 ++++++++++++++++ .../notifications/PushNotificationManager.kt | 56 +++++++- .../PushNotificationsPreferencesFragment.kt | 123 +++++++++++++++++- .../ui/viewmodels/AuthenticationViewModel.kt | 17 --- .../ui/viewmodels/MainActivityViewModel.kt | 1 + .../habitica/ui/views/login/LoginScreen.kt | 26 ---- .../views/yesterdailies/YesterdailyDialog.kt | 50 ++++++- gradle/libs.versions.toml | 2 + 16 files changed, 396 insertions(+), 50 deletions(-) create mode 100644 Habitica/src/main/java/com/habitrpg/android/habitica/helpers/notifications/HabiticaUnifiedPushService.kt 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 fbee87d8d..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 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/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 d065d1626..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( @@ -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() { - val unifiedPushUrl = sharedPreferences.getString(UNIFIED_PUSH_SERVER_KEY, null)?.trim() + 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"] = unifiedPushUrl + 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 @@ -157,7 +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" - private const val UNIFIED_PUSH_SERVER_KEY = "unified_push_server_url" + 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/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 732baac01..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 @@ -83,10 +83,6 @@ class AuthenticationViewModel @Inject constructor( 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) } @@ -109,21 +105,9 @@ class AuthenticationViewModel @Inject constructor( 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) } @@ -337,7 +321,6 @@ class AuthenticationViewModel @Inject constructor( companion object { private const val SERVER_OVERRIDE_KEY = "server_url" - private const val UNIFIED_PUSH_SERVER_KEY = "unified_push_server_url" private const val DEV_OPTIONS_UNLOCKED_KEY = "dev_options_unlocked" } } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/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 d1867f515..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 @@ -85,7 +85,6 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi var emailFieldState by remember { mutableStateOf(LoginFieldState.DEFAULT) } var showServerDialog by remember { mutableStateOf(false) } var customServerUrl by remember { mutableStateOf(authenticationViewModel.currentServerSelection()) } - var unifiedPushUrl by remember { mutableStateOf(authenticationViewModel.currentUnifiedPushServer()) } var serverError by remember { mutableStateOf(null) } val invalidServerMessage = stringResource(R.string.custom_server_invalid) val context = LocalContext.current @@ -96,7 +95,6 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi LaunchedEffect(showServerDialog) { if (showServerDialog) { customServerUrl = authenticationViewModel.currentServerSelection() - unifiedPushUrl = authenticationViewModel.currentUnifiedPushServer() serverError = null devTapCount = 0 } @@ -336,27 +334,6 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi ) } 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, @@ -366,7 +343,6 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi onClick = { val applied = authenticationViewModel.applyServerOverride(customServerUrl) if (applied) { - authenticationViewModel.updateUnifiedPushServer(unifiedPushUrl) serverError = null showServerDialog = false } else { @@ -382,9 +358,7 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi TextButton( onClick = { authenticationViewModel.resetServerOverride() - authenticationViewModel.updateUnifiedPushServer(null) customServerUrl = authenticationViewModel.currentServerSelection() - unifiedPushUrl = authenticationViewModel.currentUnifiedPushServer() serverError = null showServerDialog = false } 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 5c091524d..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 @@ -26,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 @@ -37,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 @@ -93,6 +98,7 @@ class YesterdailyDialog private constructor( MainScope().launch(ExceptionHandler.coroutine()) { userRepository.runCron(completedTasks) } + markShownToday(context, userId) displayedDialog = null } @@ -232,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, @@ -254,6 +261,9 @@ class YesterdailyDialog private constructor( if (userRepository.isClosed) { return@launchCatching } + if (hasShownToday(hostActivity, userId)) { + return@launchCatching + } val user = userRepository.getUser().firstOrNull() if (user?.needsCron != true) { return@launchCatching @@ -289,10 +299,12 @@ class YesterdailyDialog private constructor( ) if (sortedTasks?.isNotEmpty() == true) { + markShownToday(hostActivity, userId) displayedDialog = WeakReference( showDialog( hostActivity, + userId, userRepository, taskRepository, sortedTasks @@ -300,6 +312,7 @@ class YesterdailyDialog private constructor( ) } else { lastCronRun = Date() + markShownToday(hostActivity, userId) userRepository.runCron() } } @@ -308,11 +321,12 @@ 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) { @@ -323,5 +337,39 @@ class YesterdailyDialog private constructor( } 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/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" }