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