Merge pull request #1 from sudoxnym/feature/unifiedpush-gate

Feature/unifiedpush gate
This commit is contained in:
sudoxnym 2025-10-10 18:19:25 -06:00 committed by GitHub
commit 82795105c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 673 additions and 28 deletions

View file

@ -323,6 +323,13 @@
android:name=".widget.TodosWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<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"
android:enabled="true"
android:exported="true">

View file

@ -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)

View file

@ -43,6 +43,15 @@
<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_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_habitica_open_source">Habitica is available as open source software on Github</string>
@ -1641,4 +1650,19 @@
<item quantity="one">%d Item pending</item>
<item quantity="other">%d Items pending</item>
</plurals>
<string name="custom_server_content_description">Server options</string>
<string name="custom_server_title">Server configuration</string>
<string name="custom_server_label">Server URL</string>
<string name="custom_server_placeholder">https://habitica.com</string>
<string name="custom_server_apply">Apply</string>
<string name="custom_server_reset">Reset to default</string>
<string name="custom_server_invalid">Couldnt understand that server address. Include http:// or https:// and a host name.</string>
<string name="dev_options_warning">Warning: Only use these developer settings if you know what youre doing.</string>
<plurals name="dev_options_taps_remaining">
<item quantity="one">Developer options unlock in %d tap.</item>
<item quantity="other">Developer options unlock in %d taps.</item>
</plurals>
<string name="dev_options_unlocked">Developer options unlocked.</string>
<string name="custom_up_server_label">UnifiedPush server URL</string>
<string name="custom_up_server_placeholder">https://example.com</string>
</resources>

View file

@ -237,6 +237,17 @@
<PreferenceCategory android:title="Push Notifications"
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
android:key="preference_push_you_won_challenge"
android:defaultValue="true"

View file

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

View file

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

View file

@ -329,6 +329,11 @@ interface ApiClient {
// Push notifications
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 getChallengeTasks(challengeId: String): TaskList?

View file

@ -918,6 +918,17 @@ class ApiClientImpl(
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>? {
return process { apiService.deletePushDevice(regId) }
}

View file

@ -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)
}

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.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<String, String>()
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,

View file

@ -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 {

View file

@ -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
}
}
}
}

View file

@ -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<String?>(null)
val usernameIssues: Flow<String?> = _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"
}
}

View file

@ -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

View file

@ -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<String?>(null) }
val invalidServerMessage = stringResource(R.string.custom_server_invalid)
val context = LocalContext.current
var devTapCount by remember { mutableStateOf(0) }
var devOptionsUnlocked by remember { mutableStateOf(authenticationViewModel.isDevOptionsUnlocked()) }
val requiredTapCount = 7
LaunchedEffect(showServerDialog) {
if (showServerDialog) {
customServerUrl = authenticationViewModel.currentServerSelection()
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))
}
}
}
)
}
}

View file

@ -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<Task>
@ -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<YesterdailyDialog>? = null
internal var lastCronRun: Date? = null
private val lastShownByUser: MutableMap<String, Long> = 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<Task>
): 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"
}
}

View file

@ -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.

View file

@ -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)

View file

@ -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" }

Binary file not shown.

Binary file not shown.

Binary file not shown.