mirror of
https://github.com/sudoxnym/habitica-android.git
synced 2026-04-14 11:46:32 +00:00
Merge pull request #1 from sudoxnym/feature/unifiedpush-gate
Feature/unifiedpush gate
This commit is contained in:
commit
82795105c4
23 changed files with 673 additions and 28 deletions
|
|
@ -323,6 +323,13 @@
|
||||||
android:name=".widget.TodosWidgetService"
|
android:name=".widget.TodosWidgetService"
|
||||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||||
<service android:name=".widget.HabitButtonWidgetService"/>
|
<service android:name=".widget.HabitButtonWidgetService"/>
|
||||||
|
<service
|
||||||
|
android:name=".helpers.notifications.HabiticaUnifiedPushService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
<service android:name=".receivers.DeviceCommunicationService"
|
<service android:name=".receivers.DeviceCommunicationService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,9 @@ dependencies {
|
||||||
implementation(libs.credentials)
|
implementation(libs.credentials)
|
||||||
implementation(libs.credentials.playServicesAuth)
|
implementation(libs.credentials.playServicesAuth)
|
||||||
implementation(libs.googleid)
|
implementation(libs.googleid)
|
||||||
|
implementation(libs.unifiedpush.connector) {
|
||||||
|
exclude(group = "com.google.protobuf", module = "protobuf-java")
|
||||||
|
}
|
||||||
|
|
||||||
implementation(libs.flexbox)
|
implementation(libs.flexbox)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,15 @@
|
||||||
<string name="preference_push_invited_to_guild">Invited to Guild</string>
|
<string name="preference_push_invited_to_guild">Invited to Guild</string>
|
||||||
<string name="preference_push_your_quest_has_begun">Your Quest has Begun</string>
|
<string name="preference_push_your_quest_has_begun">Your Quest has Begun</string>
|
||||||
<string name="preference_push_invited_to_quest">Invited to Quest</string>
|
<string name="preference_push_invited_to_quest">Invited to Quest</string>
|
||||||
|
<string name="unified_push_provider_title">UnifiedPush provider</string>
|
||||||
|
<string name="unified_push_provider_summary">Choose which distributor to use for UnifiedPush notifications.</string>
|
||||||
|
<string name="unified_push_provider_disabled">UnifiedPush disabled</string>
|
||||||
|
<string name="unified_push_provider_unavailable">No UnifiedPush distributors found. Install a compatible distributor to enable this option.</string>
|
||||||
|
<string name="unified_push_test_action">Send test UnifiedPush notification</string>
|
||||||
|
<string name="unified_push_test_summary">Send a test UnifiedPush notification to confirm your setup.</string>
|
||||||
|
<string name="unified_push_test_unavailable">Install a UnifiedPush distributor to send test notifications.</string>
|
||||||
|
<string name="unified_push_test_success">UnifiedPush test notification sent.</string>
|
||||||
|
<string name="unified_push_test_error">Couldn’t send UnifiedPush test notification.</string>
|
||||||
|
|
||||||
<string name="about_libraries">Libraries</string>
|
<string name="about_libraries">Libraries</string>
|
||||||
<string name="about_habitica_open_source">Habitica is available as open source software on Github</string>
|
<string name="about_habitica_open_source">Habitica is available as open source software on Github</string>
|
||||||
|
|
@ -1641,4 +1650,19 @@
|
||||||
<item quantity="one">%d Item pending</item>
|
<item quantity="one">%d Item pending</item>
|
||||||
<item quantity="other">%d Items pending</item>
|
<item quantity="other">%d Items pending</item>
|
||||||
</plurals>
|
</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">Couldn’t 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 you’re 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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,17 @@
|
||||||
|
|
||||||
<PreferenceCategory android:title="Push Notifications"
|
<PreferenceCategory android:title="Push Notifications"
|
||||||
android:layout="@layout/preference_category">
|
android:layout="@layout/preference_category">
|
||||||
|
<ListPreference
|
||||||
|
android:key="preference_unified_push_provider"
|
||||||
|
android:title="@string/unified_push_provider_title"
|
||||||
|
android:summary="@string/unified_push_provider_summary"
|
||||||
|
android:layout="@layout/preference_child_summary"
|
||||||
|
android:dialogTitle="@string/unified_push_provider_title"/>
|
||||||
|
<Preference
|
||||||
|
android:key="preference_unified_push_test"
|
||||||
|
android:title="@string/unified_push_test_action"
|
||||||
|
android:summary="@string/unified_push_test_summary"
|
||||||
|
android:layout="@layout/preference_child_summary"/>
|
||||||
<CheckBoxPreference
|
<CheckBoxPreference
|
||||||
android:key="preference_push_you_won_challenge"
|
android:key="preference_push_you_won_challenge"
|
||||||
android:defaultValue="true"
|
android:defaultValue="true"
|
||||||
|
|
|
||||||
|
|
@ -330,12 +330,14 @@ abstract class HabiticaBaseApplication : Application(), Application.ActivityLife
|
||||||
val lightMode = preferences.getString("theme_mode", "system")
|
val lightMode = preferences.getString("theme_mode", "system")
|
||||||
val launchScreen = preferences.getString("launch_screen", "")
|
val launchScreen = preferences.getString("launch_screen", "")
|
||||||
|
|
||||||
// set the user and refreshed token in the push manager, so we can remove the push device
|
if (user != null) {
|
||||||
if (deviceToken.isNotEmpty() && user != null) {
|
|
||||||
pushManager?.setUser(user)
|
pushManager?.setUser(user)
|
||||||
|
}
|
||||||
|
if (deviceToken.isNotEmpty()) {
|
||||||
pushManager?.refreshedToken = deviceToken
|
pushManager?.refreshedToken = deviceToken
|
||||||
pushManager?.removePushDeviceUsingStoredToken()
|
pushManager?.removePushDeviceUsingStoredToken()
|
||||||
}
|
}
|
||||||
|
pushManager?.unregisterUnifiedPushEndpoint()
|
||||||
|
|
||||||
deleteDatabase(context)
|
deleteDatabase(context)
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
|
|
|
||||||
|
|
@ -525,6 +525,11 @@ interface ApiService {
|
||||||
@Body pushDeviceData: Map<String, String>
|
@Body pushDeviceData: Map<String, String>
|
||||||
): HabitResponse<List<Void>>
|
): HabitResponse<List<Void>>
|
||||||
|
|
||||||
|
@POST("user/push-devices/test")
|
||||||
|
suspend fun sendUnifiedPushTest(
|
||||||
|
@Body data: Map<String, String>
|
||||||
|
): HabitResponse<Void>
|
||||||
|
|
||||||
@DELETE("user/push-devices/{regId}")
|
@DELETE("user/push-devices/{regId}")
|
||||||
suspend fun deletePushDevice(
|
suspend fun deletePushDevice(
|
||||||
@Path("regId") regId: String
|
@Path("regId") regId: String
|
||||||
|
|
|
||||||
|
|
@ -329,6 +329,11 @@ interface ApiClient {
|
||||||
// Push notifications
|
// Push notifications
|
||||||
suspend fun addPushDevice(pushDeviceData: Map<String, String>): List<Void>?
|
suspend fun addPushDevice(pushDeviceData: Map<String, String>): List<Void>?
|
||||||
|
|
||||||
|
suspend fun sendUnifiedPushTest(
|
||||||
|
regId: String? = null,
|
||||||
|
message: String? = null
|
||||||
|
): Void?
|
||||||
|
|
||||||
suspend fun deletePushDevice(regId: String): List<Void>?
|
suspend fun deletePushDevice(regId: String): List<Void>?
|
||||||
|
|
||||||
suspend fun getChallengeTasks(challengeId: String): TaskList?
|
suspend fun getChallengeTasks(challengeId: String): TaskList?
|
||||||
|
|
|
||||||
|
|
@ -918,6 +918,17 @@ class ApiClientImpl(
|
||||||
return process { apiService.addPushDevice(pushDeviceData) }
|
return process { apiService.addPushDevice(pushDeviceData) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun sendUnifiedPushTest(
|
||||||
|
regId: String?,
|
||||||
|
message: String?
|
||||||
|
): Void? {
|
||||||
|
val body = mutableMapOf<String, String>()
|
||||||
|
body["type"] = "unifiedpush"
|
||||||
|
regId?.let { body["regId"] = it }
|
||||||
|
message?.takeIf { it.isNotBlank() }?.let { body["message"] = it }
|
||||||
|
return process { apiService.sendUnifiedPushTest(body) }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun deletePushDevice(regId: String): List<Void>? {
|
override suspend fun deletePushDevice(regId: String): List<Void>? {
|
||||||
return process { apiService.deletePushDevice(regId) }
|
return process { apiService.deletePushDevice(regId) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,14 +76,19 @@ object Analytics {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initialize(context: Context) {
|
fun initialize(context: Context) {
|
||||||
amplitude =
|
val amplitudeAppId = context.getString(R.string.amplitude_app_id)
|
||||||
Amplitude(
|
if (amplitudeAppId.isNullOrBlank()) {
|
||||||
Configuration(
|
// No amplitude configuration provided; skip amplitude setup for this build.
|
||||||
context.getString(R.string.amplitude_app_id),
|
} else {
|
||||||
context,
|
amplitude =
|
||||||
optOut = true,
|
Amplitude(
|
||||||
|
Configuration(
|
||||||
|
amplitudeAppId,
|
||||||
|
context,
|
||||||
|
optOut = true,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
firebase = FirebaseAnalytics.getInstance(context)
|
firebase = FirebaseAnalytics.getInstance(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import com.habitrpg.android.habitica.helpers.HitType
|
||||||
import com.habitrpg.android.habitica.models.user.User
|
import com.habitrpg.android.habitica.models.user.User
|
||||||
import com.habitrpg.common.habitica.helpers.launchCatching
|
import com.habitrpg.common.habitica.helpers.launchCatching
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
|
import org.unifiedpush.android.connector.UnifiedPush
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class PushNotificationManager(
|
class PushNotificationManager(
|
||||||
|
|
@ -76,6 +77,7 @@ class PushNotificationManager(
|
||||||
// catchy catch
|
// catchy catch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
addUnifiedPushDeviceIfConfigured()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addRefreshToken() {
|
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() {
|
suspend fun removePushDeviceUsingStoredToken() {
|
||||||
if (this.refreshedToken.isEmpty() || !userHasPushDevice()) {
|
if (this.refreshedToken.isEmpty() || !userHasPushDevice()) {
|
||||||
return
|
return
|
||||||
|
|
@ -140,6 +207,7 @@ class PushNotificationManager(
|
||||||
const val CONTENT_RELEASE_NOTIFICATION_KEY = "contentRelease"
|
const val CONTENT_RELEASE_NOTIFICATION_KEY = "contentRelease"
|
||||||
const val G1G1_PROMO_KEY = "g1g1Promo"
|
const val G1G1_PROMO_KEY = "g1g1Promo"
|
||||||
const val DEVICE_TOKEN_PREFERENCE_KEY = "device-token-preference"
|
const val DEVICE_TOKEN_PREFERENCE_KEY = "device-token-preference"
|
||||||
|
const val UNIFIED_PUSH_ENDPOINT_KEY = "unified_push_server_url"
|
||||||
|
|
||||||
fun displayNotification(
|
fun displayNotification(
|
||||||
remoteMessage: RemoteMessage,
|
remoteMessage: RemoteMessage,
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ class OnboardingActivity: BaseActivity() {
|
||||||
|
|
||||||
val authenticationViewModel: AuthenticationViewModel by viewModels()
|
val authenticationViewModel: AuthenticationViewModel by viewModels()
|
||||||
|
|
||||||
val currentStep = mutableStateOf(OnboardingSteps.SETUP)
|
val currentStep = mutableStateOf(OnboardingSteps.LOGIN)
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var configManager: AppConfigManager
|
lateinit var configManager: AppConfigManager
|
||||||
|
|
@ -91,6 +91,11 @@ class OnboardingActivity: BaseActivity() {
|
||||||
// Set default values to avoid null-responses when requesting unedited settings
|
// Set default values to avoid null-responses when requesting unedited settings
|
||||||
PreferenceManager.setDefaultValues(this, R.xml.preferences_fragment, false)
|
PreferenceManager.setDefaultValues(this, R.xml.preferences_fragment, false)
|
||||||
|
|
||||||
|
if (authenticationViewModel.hostConfig.hasAuthentication()) {
|
||||||
|
startMainActivity()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
binding.composeView.setContent {
|
binding.composeView.setContent {
|
||||||
val step by currentStep
|
val step by currentStep
|
||||||
HabiticaTheme {
|
HabiticaTheme {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
package com.habitrpg.android.habitica.ui.fragments.preferences
|
package com.habitrpg.android.habitica.ui.fragments.preferences
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.preference.CheckBoxPreference
|
import androidx.preference.CheckBoxPreference
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import androidx.preference.Preference
|
||||||
import com.habitrpg.android.habitica.models.user.User
|
import com.habitrpg.android.habitica.models.user.User
|
||||||
import com.habitrpg.common.habitica.helpers.launchCatching
|
import com.habitrpg.common.habitica.helpers.launchCatching
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.unifiedpush.android.connector.UnifiedPush
|
||||||
|
import javax.inject.Inject
|
||||||
|
import com.habitrpg.android.habitica.R
|
||||||
|
import com.habitrpg.android.habitica.helpers.notifications.PushNotificationManager
|
||||||
|
import com.habitrpg.android.habitica.data.ApiClient
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class PushNotificationsPreferencesFragment :
|
class PushNotificationsPreferencesFragment :
|
||||||
|
|
@ -13,10 +22,19 @@ class PushNotificationsPreferencesFragment :
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
private var isInitialSet: Boolean = true
|
private var isInitialSet: Boolean = true
|
||||||
private var isSettingUser: Boolean = false
|
private var isSettingUser: Boolean = false
|
||||||
|
private var unifiedPushPreference: ListPreference? = null
|
||||||
|
private var unifiedPushTestPreference: Preference? = null
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var pushNotificationManager: PushNotificationManager
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var apiClient: ApiClient
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
|
preferenceScreen.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
|
||||||
|
updateUnifiedPushPreference()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
|
@ -24,7 +42,24 @@ class PushNotificationsPreferencesFragment :
|
||||||
preferenceScreen.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this)
|
preferenceScreen.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setupPreferences() { // no-on
|
override fun setupPreferences() {
|
||||||
|
unifiedPushPreference = findPreference("preference_unified_push_provider") as? ListPreference
|
||||||
|
unifiedPushPreference?.setOnPreferenceChangeListener { preference, newValue ->
|
||||||
|
val packageName = (newValue as? String).orEmpty()
|
||||||
|
handleUnifiedPushSelection(packageName)
|
||||||
|
val listPreference = preference as? ListPreference
|
||||||
|
val index = listPreference?.entryValues?.indexOf(packageName) ?: -1
|
||||||
|
if (index >= 0 && listPreference?.entries?.size.orZero() > index) {
|
||||||
|
listPreference?.summary = listPreference?.entries?.get(index)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
unifiedPushTestPreference = findPreference("preference_unified_push_test")
|
||||||
|
unifiedPushTestPreference?.setOnPreferenceClickListener {
|
||||||
|
triggerUnifiedPushTest()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
updateUnifiedPushPreference()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setUser(user: User?) {
|
override fun setUser(user: User?) {
|
||||||
|
|
@ -128,4 +163,90 @@ class PushNotificationsPreferencesFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleUnifiedPushSelection(packageName: String) {
|
||||||
|
val context = context ?: return
|
||||||
|
if (packageName.isEmpty()) {
|
||||||
|
UnifiedPush.removeDistributor(context)
|
||||||
|
pushNotificationManager.unregisterUnifiedPushEndpoint()
|
||||||
|
} else {
|
||||||
|
UnifiedPush.saveDistributor(context, packageName)
|
||||||
|
UnifiedPush.register(context)
|
||||||
|
pushNotificationManager.ensureUnifiedPushRegistration()
|
||||||
|
}
|
||||||
|
updateUnifiedPushPreference()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateUnifiedPushPreference() {
|
||||||
|
val preference = unifiedPushPreference ?: return
|
||||||
|
val context = context ?: return
|
||||||
|
val distributors = UnifiedPush.getDistributors(context)
|
||||||
|
if (distributors.isEmpty()) {
|
||||||
|
preference.isEnabled = false
|
||||||
|
preference.summary = getString(R.string.unified_push_provider_unavailable)
|
||||||
|
preference.entries = arrayOf(getString(R.string.unified_push_provider_disabled))
|
||||||
|
preference.entryValues = arrayOf("")
|
||||||
|
preference.value = ""
|
||||||
|
unifiedPushTestPreference?.apply {
|
||||||
|
isVisible = false
|
||||||
|
isEnabled = false
|
||||||
|
summary = getString(R.string.unified_push_test_unavailable)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preference.isEnabled = true
|
||||||
|
val pm = context.packageManager
|
||||||
|
val entries = mutableListOf(getString(R.string.unified_push_provider_disabled))
|
||||||
|
val values = mutableListOf("")
|
||||||
|
|
||||||
|
distributors.distinct().forEach { packageName ->
|
||||||
|
val label = resolveAppLabel(pm, packageName)
|
||||||
|
entries.add(label)
|
||||||
|
values.add(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
preference.entries = entries.toTypedArray()
|
||||||
|
preference.entryValues = values.toTypedArray()
|
||||||
|
|
||||||
|
val savedDistributor = UnifiedPush.getSavedDistributor(context)
|
||||||
|
val resolvedValue = savedDistributor?.takeIf { values.contains(it) } ?: ""
|
||||||
|
preference.value = resolvedValue
|
||||||
|
|
||||||
|
val selectedIndex = values.indexOf(resolvedValue).takeIf { it >= 0 } ?: 0
|
||||||
|
preference.summary = entries[selectedIndex]
|
||||||
|
|
||||||
|
unifiedPushTestPreference?.apply {
|
||||||
|
isVisible = true
|
||||||
|
isEnabled = true
|
||||||
|
summary = getString(R.string.unified_push_test_summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveAppLabel(pm: PackageManager, packageName: String): String {
|
||||||
|
return runCatching {
|
||||||
|
val applicationInfo = pm.getApplicationInfo(packageName, 0)
|
||||||
|
pm.getApplicationLabel(applicationInfo).toString()
|
||||||
|
}.getOrDefault(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int?.orZero(): Int = this ?: 0
|
||||||
|
|
||||||
|
private fun triggerUnifiedPushTest() {
|
||||||
|
val context = context ?: return
|
||||||
|
val preference = unifiedPushTestPreference ?: return
|
||||||
|
preference.isEnabled = false
|
||||||
|
lifecycleScope.launchCatching({
|
||||||
|
preference.isEnabled = true
|
||||||
|
Toast.makeText(context, getString(R.string.unified_push_test_error), Toast.LENGTH_LONG).show()
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
pushNotificationManager.ensureUnifiedPushRegistration()
|
||||||
|
apiClient.sendUnifiedPushTest()
|
||||||
|
Toast.makeText(context, getString(R.string.unified_push_test_success), Toast.LENGTH_SHORT).show()
|
||||||
|
} finally {
|
||||||
|
preference.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.tasks.await
|
import kotlinx.coroutines.tasks.await
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AuthenticationViewModel @Inject constructor(
|
class AuthenticationViewModel @Inject constructor(
|
||||||
|
|
@ -78,6 +79,50 @@ class AuthenticationViewModel @Inject constructor(
|
||||||
private var _usernameIssues = MutableStateFlow<String?>(null)
|
private var _usernameIssues = MutableStateFlow<String?>(null)
|
||||||
val usernameIssues: Flow<String?> = _usernameIssues
|
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(
|
fun validateInputs(
|
||||||
username: String,
|
username: String,
|
||||||
password: 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ constructor(
|
||||||
}
|
}
|
||||||
Analytics.setUserProperty("level", user.stats?.lvl?.toString() ?: "")
|
Analytics.setUserProperty("level", user.stats?.lvl?.toString() ?: "")
|
||||||
pushNotificationManager.setUser(user)
|
pushNotificationManager.setUser(user)
|
||||||
|
pushNotificationManager.ensureUnifiedPushRegistration()
|
||||||
if (!pushNotificationManager.notificationPermissionEnabled()) {
|
if (!pushNotificationManager.notificationPermissionEnabled()) {
|
||||||
if (sharedPreferences.getBoolean("usePushNotifications", true)) {
|
if (sharedPreferences.getBoolean("usePushNotifications", true)) {
|
||||||
requestNotificationPermission.value = true
|
requestNotificationPermission.value = true
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.habitrpg.android.habitica.ui.views.login
|
package com.habitrpg.android.habitica.ui.views.login
|
||||||
|
|
||||||
import android.util.Patterns
|
import android.util.Patterns
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.EaseInOut
|
import androidx.compose.animation.core.EaseInOut
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
|
@ -13,6 +14,7 @@ import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
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.padding
|
||||||
import androidx.compose.foundation.layout.systemBars
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.ProvideTextStyle
|
import androidx.compose.material3.ProvideTextStyle
|
||||||
import androidx.compose.material3.Text
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
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.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -74,6 +83,22 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi
|
||||||
var passwordFieldState by remember { mutableStateOf(LoginFieldState.DEFAULT) }
|
var passwordFieldState by remember { mutableStateOf(LoginFieldState.DEFAULT) }
|
||||||
var email by authenticationViewModel.email
|
var email by authenticationViewModel.email
|
||||||
var emailFieldState by remember { mutableStateOf(LoginFieldState.DEFAULT) }
|
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()) {
|
Box(modifier.fillMaxSize()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
|
|
@ -125,6 +150,40 @@ fun LoginScreen(authenticationViewModel: AuthenticationViewModel, onNextOnboardi
|
||||||
Image(painterResource(R.drawable.arrow_back), contentDescription = null)
|
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(
|
val logoPadding by animateDpAsState(
|
||||||
if (loginScreenState == LoginScreenState.INITIAL) {
|
if (loginScreenState == LoginScreenState.INITIAL) {
|
||||||
120.dp
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.habitrpg.android.habitica.R
|
import com.habitrpg.android.habitica.R
|
||||||
import com.habitrpg.android.habitica.data.TaskRepository
|
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.ExceptionHandler
|
||||||
import com.habitrpg.common.habitica.helpers.launchCatching
|
import com.habitrpg.common.habitica.helpers.launchCatching
|
||||||
import com.habitrpg.shared.habitica.models.tasks.TaskType
|
import com.habitrpg.shared.habitica.models.tasks.TaskType
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
|
@ -36,9 +40,11 @@ import java.util.Date
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.time.DurationUnit
|
import kotlin.time.DurationUnit
|
||||||
import kotlin.time.toDuration
|
import kotlin.time.toDuration
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
|
||||||
class YesterdailyDialog private constructor(
|
class YesterdailyDialog private constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
private val userId: String,
|
||||||
private val userRepository: UserRepository,
|
private val userRepository: UserRepository,
|
||||||
private val taskRepository: TaskRepository,
|
private val taskRepository: TaskRepository,
|
||||||
private val tasks: List<Task>
|
private val tasks: List<Task>
|
||||||
|
|
@ -92,6 +98,7 @@ class YesterdailyDialog private constructor(
|
||||||
MainScope().launch(ExceptionHandler.coroutine()) {
|
MainScope().launch(ExceptionHandler.coroutine()) {
|
||||||
userRepository.runCron(completedTasks)
|
userRepository.runCron(completedTasks)
|
||||||
}
|
}
|
||||||
|
markShownToday(context, userId)
|
||||||
displayedDialog = null
|
displayedDialog = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,6 +238,7 @@ class YesterdailyDialog private constructor(
|
||||||
companion object {
|
companion object {
|
||||||
private var displayedDialog: WeakReference<YesterdailyDialog>? = null
|
private var displayedDialog: WeakReference<YesterdailyDialog>? = null
|
||||||
internal var lastCronRun: Date? = null
|
internal var lastCronRun: Date? = null
|
||||||
|
private val lastShownByUser: MutableMap<String, Long> = mutableMapOf()
|
||||||
|
|
||||||
fun showDialogIfNeeded(
|
fun showDialogIfNeeded(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
|
|
@ -239,11 +247,23 @@ class YesterdailyDialog private constructor(
|
||||||
taskRepository: TaskRepository
|
taskRepository: TaskRepository
|
||||||
) {
|
) {
|
||||||
if (userRepository != null && userId != null) {
|
if (userRepository != null && userId != null) {
|
||||||
MainScope().launchCatching {
|
val lifecycleOwner = activity as? LifecycleOwner ?: return
|
||||||
|
lifecycleOwner.lifecycleScope.launchCatching {
|
||||||
delay(500.toDuration(DurationUnit.MILLISECONDS))
|
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) {
|
if (userRepository.isClosed) {
|
||||||
return@launchCatching
|
return@launchCatching
|
||||||
}
|
}
|
||||||
|
if (hasShownToday(hostActivity, userId)) {
|
||||||
|
return@launchCatching
|
||||||
|
}
|
||||||
val user = userRepository.getUser().firstOrNull()
|
val user = userRepository.getUser().firstOrNull()
|
||||||
if (user?.needsCron != true) {
|
if (user?.needsCron != true) {
|
||||||
return@launchCatching
|
return@launchCatching
|
||||||
|
|
@ -279,10 +299,12 @@ class YesterdailyDialog private constructor(
|
||||||
)
|
)
|
||||||
|
|
||||||
if (sortedTasks?.isNotEmpty() == true) {
|
if (sortedTasks?.isNotEmpty() == true) {
|
||||||
|
markShownToday(hostActivity, userId)
|
||||||
displayedDialog =
|
displayedDialog =
|
||||||
WeakReference(
|
WeakReference(
|
||||||
showDialog(
|
showDialog(
|
||||||
activity,
|
hostActivity,
|
||||||
|
userId,
|
||||||
userRepository,
|
userRepository,
|
||||||
taskRepository,
|
taskRepository,
|
||||||
sortedTasks
|
sortedTasks
|
||||||
|
|
@ -290,6 +312,7 @@ class YesterdailyDialog private constructor(
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
lastCronRun = Date()
|
lastCronRun = Date()
|
||||||
|
markShownToday(hostActivity, userId)
|
||||||
userRepository.runCron()
|
userRepository.runCron()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -298,17 +321,55 @@ class YesterdailyDialog private constructor(
|
||||||
|
|
||||||
private fun showDialog(
|
private fun showDialog(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
|
userId: String,
|
||||||
userRepository: UserRepository,
|
userRepository: UserRepository,
|
||||||
taskRepository: TaskRepository,
|
taskRepository: TaskRepository,
|
||||||
tasks: List<Task>
|
tasks: List<Task>
|
||||||
): YesterdailyDialog {
|
): YesterdailyDialog {
|
||||||
val dialog = YesterdailyDialog(activity, userRepository, taskRepository, tasks)
|
val dialog = YesterdailyDialog(activity, userId, userRepository, taskRepository, tasks)
|
||||||
dialog.setCancelable(false)
|
dialog.setCancelable(false)
|
||||||
dialog.setCanceledOnTouchOutside(false)
|
dialog.setCanceledOnTouchOutside(false)
|
||||||
if (!activity.isFinishing) {
|
if (!activity.isFinishing) {
|
||||||
dialog.show()
|
dialog.show()
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
displayedDialog = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hasShownToday(context: Context, userId: String): Boolean {
|
||||||
|
val lastShown = lastShownByUser[userId] ?: loadLastShown(context, userId)
|
||||||
|
if (lastShown == 0L) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val lastShownDate = Instant.ofEpochMilli(lastShown).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val hasShown = lastShownDate == today
|
||||||
|
if (hasShown) {
|
||||||
|
lastShownByUser[userId] = lastShown
|
||||||
|
}
|
||||||
|
return hasShown
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markShownToday(context: Context, userId: String) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
lastShownByUser[userId] = now
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
.edit()
|
||||||
|
.putLong(prefKeyForUser(userId), now)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLastShown(context: Context, userId: String): Long {
|
||||||
|
val stored = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
.getLong(prefKeyForUser(userId), 0L)
|
||||||
|
if (stored != 0L) {
|
||||||
|
lastShownByUser[userId] = stored
|
||||||
|
}
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefKeyForUser(userId: String): String = "yesterdaily_last_shown_$userId"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
##### [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.
|
If you Watch this repository, GitHub will send you an email every time we publish an update.
|
||||||
|
|
||||||
## Contributing
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,20 +22,19 @@ class HostConfig {
|
||||||
|
|
||||||
constructor(sharedPreferences: SharedPreferences, keyHelper: KeyHelper?, context: Context) {
|
constructor(sharedPreferences: SharedPreferences, keyHelper: KeyHelper?, context: Context) {
|
||||||
this.port = BuildConfig.PORT
|
this.port = BuildConfig.PORT
|
||||||
if (BuildConfig.DEBUG) {
|
val storedAddress = sharedPreferences.getString("server_url", null)?.takeIf { it.isNotBlank() }
|
||||||
this.address = BuildConfig.BASE_URL
|
|
||||||
if (BuildConfig.TEST_USER_ID.isNotBlank()) {
|
if (BuildConfig.DEBUG && BuildConfig.TEST_USER_ID.isNotBlank()) {
|
||||||
userID = BuildConfig.TEST_USER_ID
|
this.address = storedAddress ?: BuildConfig.BASE_URL
|
||||||
apiKey = BuildConfig.TEST_USER_KEY
|
userID = BuildConfig.TEST_USER_ID
|
||||||
return
|
apiKey = BuildConfig.TEST_USER_KEY
|
||||||
}
|
return
|
||||||
} else {
|
}
|
||||||
val address = sharedPreferences.getString("server_url", null)
|
|
||||||
if (!address.isNullOrEmpty()) {
|
this.address = when {
|
||||||
this.address = address
|
storedAddress != null -> storedAddress
|
||||||
} else {
|
BuildConfig.DEBUG -> BuildConfig.BASE_URL
|
||||||
this.address = context.getString(com.habitrpg.common.habitica.R.string.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.userID = sharedPreferences.getString(context.getString(com.habitrpg.common.habitica.R.string.SP_userID), null) ?: ""
|
||||||
this.apiKey = loadAPIKey(sharedPreferences, keyHelper)
|
this.apiKey = loadAPIKey(sharedPreferences, keyHelper)
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ retrofit = "3.0.0"
|
||||||
shimmer = "0.5.0"
|
shimmer = "0.5.0"
|
||||||
swipeRefresh = "1.1.0"
|
swipeRefresh = "1.1.0"
|
||||||
turbine = "1.2.1"
|
turbine = "1.2.1"
|
||||||
|
unifiedpush = "3.0.10"
|
||||||
wear = "1.3.0"
|
wear = "1.3.0"
|
||||||
wearInput = "1.1.0"
|
wearInput = "1.1.0"
|
||||||
|
|
||||||
|
|
@ -153,6 +154,7 @@ test-rules = { group = "androidx.test", name = "rules", version.ref = "androidTe
|
||||||
test-runner = { group = "androidx.test", name = "runner", version.ref = "androidTestRunner" }
|
test-runner = { group = "androidx.test", name = "runner", version.ref = "androidTestRunner" }
|
||||||
text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts", version.ref = "compose" }
|
text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts", version.ref = "compose" }
|
||||||
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
|
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
|
||||||
|
unifiedpush-connector = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
|
||||||
ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" }
|
ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" }
|
||||||
viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
wear = { module = "androidx.wear:wear", version.ref = "wear" }
|
wear = { module = "androidx.wear:wear", version.ref = "wear" }
|
||||||
|
|
|
||||||
BIN
release-artifacts/Habitica-prod-release.apk
Normal file
BIN
release-artifacts/Habitica-prod-release.apk
Normal file
Binary file not shown.
BIN
release-artifacts/habitica-self-host-debug.apk
Normal file
BIN
release-artifacts/habitica-self-host-debug.apk
Normal file
Binary file not shown.
BIN
release-artifacts/habitica-self-host-release.apk
Normal file
BIN
release-artifacts/habitica-self-host-release.apk
Normal file
Binary file not shown.
Loading…
Reference in a new issue