From b12827a78b5778096da7782787d5539538c136b2 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 30 Dec 2025 17:36:59 -0500 Subject: [PATCH 1/2] Cursor, initial AI slop --- .../main/java/io/heckel/ntfy/db/Repository.kt | 11 + .../main/java/io/heckel/ntfy/db/Session.kt | 69 ++++ .../io/heckel/ntfy/msg/AccountApiService.kt | 318 ++++++++++++++++++ .../java/io/heckel/ntfy/msg/AccountManager.kt | 268 +++++++++++++++ .../java/io/heckel/ntfy/ui/AccountFragment.kt | 258 ++++++++++++++ .../java/io/heckel/ntfy/ui/DetailActivity.kt | 5 + .../heckel/ntfy/ui/DetailSettingsActivity.kt | 9 +- .../java/io/heckel/ntfy/ui/MainActivity.kt | 35 +- .../java/io/heckel/ntfy/ui/MainViewModel.kt | 7 +- .../io/heckel/ntfy/ui/SettingsActivity.kt | 38 ++- .../io/heckel/ntfy/work/AccountSyncWorker.kt | 61 ++++ .../res/layout/fragment_account_dialog.xml | 169 ++++++++++ app/src/main/res/values/strings.xml | 20 ++ app/src/main/res/values/values.xml | 1 + app/src/main/res/xml/main_preferences.xml | 6 + 15 files changed, 1270 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/db/Session.kt create mode 100644 app/src/main/java/io/heckel/ntfy/msg/AccountApiService.kt create mode 100644 app/src/main/java/io/heckel/ntfy/msg/AccountManager.kt create mode 100644 app/src/main/java/io/heckel/ntfy/ui/AccountFragment.kt create mode 100644 app/src/main/java/io/heckel/ntfy/work/AccountSyncWorker.kt create mode 100644 app/src/main/res/layout/fragment_account_dialog.xml diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 6e346efd7..91ae52edf 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -222,6 +222,16 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) } } + fun getAccountSyncWorkerVersion(): Int { + return sharedPrefs.getInt(SHARED_PREFS_ACCOUNT_SYNC_WORKER_VERSION, 0) + } + + fun setAccountSyncWorkerVersion(version: Int) { + sharedPrefs.edit { + putInt(SHARED_PREFS_ACCOUNT_SYNC_WORKER_VERSION, version) + } + } + fun setMinPriority(minPriority: Int) { if (minPriority <= MIN_PRIORITY_ANY) { sharedPrefs.edit { @@ -572,6 +582,7 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion" const val SHARED_PREFS_DELETE_WORKER_VERSION = "DeleteWorkerVersion" const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" + const val SHARED_PREFS_ACCOUNT_SYNC_WORKER_VERSION = "AccountSyncWorkerVersion" const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil" const val SHARED_PREFS_MIN_PRIORITY = "MinPriority" const val SHARED_PREFS_AUTO_DOWNLOAD_MAX_SIZE = "AutoDownload" diff --git a/app/src/main/java/io/heckel/ntfy/db/Session.kt b/app/src/main/java/io/heckel/ntfy/db/Session.kt new file mode 100644 index 000000000..188d74fbb --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/db/Session.kt @@ -0,0 +1,69 @@ +package io.heckel.ntfy.db + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import io.heckel.ntfy.util.Log + +/** + * Manages the logged-in user's session and access token. + * This is used for ntfy account login (optional feature). + * When logged in, subscriptions are synchronized with the server. + */ +class Session private constructor(private val sharedPrefs: SharedPreferences) { + + fun store(username: String, token: String, baseUrl: String) { + Log.d(TAG, "Storing session for user $username at $baseUrl") + sharedPrefs.edit { + putString(PREF_USERNAME, username) + putString(PREF_TOKEN, token) + putString(PREF_BASE_URL, baseUrl) + } + } + + fun clear() { + Log.d(TAG, "Clearing session") + sharedPrefs.edit { + remove(PREF_USERNAME) + remove(PREF_TOKEN) + remove(PREF_BASE_URL) + } + } + + fun isLoggedIn(): Boolean { + return token() != null && username() != null + } + + fun username(): String? { + return sharedPrefs.getString(PREF_USERNAME, null) + } + + fun token(): String? { + return sharedPrefs.getString(PREF_TOKEN, null) + } + + fun baseUrl(): String? { + return sharedPrefs.getString(PREF_BASE_URL, null) + } + + companion object { + private const val TAG = "NtfySession" + private const val SHARED_PREFS_ID = "NtfySession" + private const val PREF_USERNAME = "Username" + private const val PREF_TOKEN = "Token" + private const val PREF_BASE_URL = "BaseUrl" + + @Volatile + private var instance: Session? = null + + fun getInstance(context: Context): Session { + return instance ?: synchronized(this) { + val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) + val newInstance = instance ?: Session(sharedPrefs) + instance = newInstance + newInstance + } + } + } +} + diff --git a/app/src/main/java/io/heckel/ntfy/msg/AccountApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/AccountApiService.kt new file mode 100644 index 000000000..4d87d5cef --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/AccountApiService.kt @@ -0,0 +1,318 @@ +package io.heckel.ntfy.msg + +import android.content.Context +import android.os.Build +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import io.heckel.ntfy.BuildConfig +import io.heckel.ntfy.db.Session +import io.heckel.ntfy.util.Log +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +/** + * Service for ntfy account API endpoints. + * Used for login, logout, account sync, and subscription management. + * See https://docs.ntfy.sh/publish/#account-api + */ +class AccountApiService(private val context: Context) { + private val gson = Gson() + private val session = Session.getInstance(context) + private val client = OkHttpClient.Builder() + .callTimeout(15, TimeUnit.SECONDS) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() + + /** + * Login with username/password to get a bearer token. + * POST /v1/account/token + */ + fun login(baseUrl: String, username: String, password: String): String { + val url = accountTokenUrl(baseUrl) + Log.d(TAG, "Logging in to $url as $username") + val request = Request.Builder() + .url(url) + .post("".toRequestBody()) + .addHeader("User-Agent", USER_AGENT) + .addHeader("Authorization", basicAuth(username, password)) + .build() + client.newCall(request).execute().use { response -> + if (response.code == 401 || response.code == 403) { + throw UnauthorizedException("Invalid username or password") + } else if (!response.isSuccessful) { + throw Exception("Login failed: ${response.code}") + } + val body = response.body.string() + val tokenResponse = gson.fromJson(body, TokenResponse::class.java) + if (tokenResponse.token.isNullOrEmpty()) { + throw Exception("Login failed: No token in response") + } + Log.d(TAG, "Login successful for $username") + return tokenResponse.token + } + } + + /** + * Logout and invalidate the current token. + * DELETE /v1/account/token + */ + fun logout(baseUrl: String, token: String) { + val url = accountTokenUrl(baseUrl) + Log.d(TAG, "Logging out from $url") + val request = Request.Builder() + .url(url) + .delete() + .addHeader("User-Agent", USER_AGENT) + .addHeader("Authorization", bearerAuth(token)) + .build() + try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Log.w(TAG, "Logout request failed: ${response.code}") + } + } + } catch (e: Exception) { + Log.w(TAG, "Logout request failed", e) + } + } + + /** + * Extend the current token's expiration. + * PATCH /v1/account/token + */ + fun extendToken(baseUrl: String, token: String) { + val url = accountTokenUrl(baseUrl) + Log.d(TAG, "Extending token at $url") + val request = Request.Builder() + .url(url) + .patch("".toRequestBody()) + .addHeader("User-Agent", USER_AGENT) + .addHeader("Authorization", bearerAuth(token)) + .build() + client.newCall(request).execute().use { response -> + if (response.code == 401 || response.code == 403) { + throw UnauthorizedException("Token expired or invalid") + } else if (!response.isSuccessful) { + throw Exception("Failed to extend token: ${response.code}") + } + Log.d(TAG, "Token extended successfully") + } + } + + /** + * Get account information including subscriptions. + * GET /v1/account + */ + fun getAccount(baseUrl: String, token: String): AccountResponse { + val url = accountUrl(baseUrl) + Log.d(TAG, "Fetching account from $url") + val request = Request.Builder() + .url(url) + .get() + .addHeader("User-Agent", USER_AGENT) + .addHeader("Authorization", bearerAuth(token)) + .build() + client.newCall(request).execute().use { response -> + if (response.code == 401 || response.code == 403) { + throw UnauthorizedException("Token expired or invalid") + } else if (!response.isSuccessful) { + throw Exception("Failed to get account: ${response.code}") + } + val body = response.body.string() + Log.d(TAG, "Account response: $body") + return gson.fromJson(body, AccountResponse::class.java) + } + } + + /** + * Add a subscription to the user's account. + * POST /v1/account/subscription + */ + fun addSubscription(baseUrl: String, token: String, subscriptionBaseUrl: String, topic: String): RemoteSubscription { + val url = accountSubscriptionUrl(baseUrl) + val payload = gson.toJson(AddSubscriptionRequest(subscriptionBaseUrl, topic)) + Log.d(TAG, "Adding subscription $topic at $subscriptionBaseUrl to $url") + val request = Request.Builder() + .url(url) + .post(payload.toRequestBody()) + .addHeader("User-Agent", USER_AGENT) + .addHeader("Authorization", bearerAuth(token)) + .addHeader("Content-Type", "application/json") + .build() + client.newCall(request).execute().use { response -> + if (response.code == 401 || response.code == 403) { + throw UnauthorizedException("Token expired or invalid") + } else if (!response.isSuccessful) { + throw Exception("Failed to add subscription: ${response.code}") + } + val body = response.body.string() + Log.d(TAG, "Add subscription response: $body") + return gson.fromJson(body, RemoteSubscription::class.java) + } + } + + /** + * Update a subscription in the user's account. + * PATCH /v1/account/subscription + */ + fun updateSubscription( + baseUrl: String, + token: String, + subscriptionBaseUrl: String, + topic: String, + displayName: String? = null + ): RemoteSubscription { + val url = accountSubscriptionUrl(baseUrl) + val payload = gson.toJson(UpdateSubscriptionRequest(subscriptionBaseUrl, topic, displayName)) + Log.d(TAG, "Updating subscription $topic at $url") + val request = Request.Builder() + .url(url) + .patch(payload.toRequestBody()) + .addHeader("User-Agent", USER_AGENT) + .addHeader("Authorization", bearerAuth(token)) + .addHeader("Content-Type", "application/json") + .build() + client.newCall(request).execute().use { response -> + if (response.code == 401 || response.code == 403) { + throw UnauthorizedException("Token expired or invalid") + } else if (!response.isSuccessful) { + throw Exception("Failed to update subscription: ${response.code}") + } + val body = response.body.string() + Log.d(TAG, "Update subscription response: $body") + return gson.fromJson(body, RemoteSubscription::class.java) + } + } + + /** + * Delete a subscription from the user's account. + * DELETE /v1/account/subscription + */ + fun deleteSubscription(baseUrl: String, token: String, subscriptionBaseUrl: String, topic: String) { + val url = accountSubscriptionUrl(baseUrl) + Log.d(TAG, "Deleting subscription $topic from $url") + val request = Request.Builder() + .url(url) + .delete() + .addHeader("User-Agent", USER_AGENT) + .addHeader("Authorization", bearerAuth(token)) + .addHeader("X-BaseURL", subscriptionBaseUrl) + .addHeader("X-Topic", topic) + .build() + client.newCall(request).execute().use { response -> + if (response.code == 401 || response.code == 403) { + throw UnauthorizedException("Token expired or invalid") + } else if (!response.isSuccessful) { + throw Exception("Failed to delete subscription: ${response.code}") + } + Log.d(TAG, "Subscription deleted successfully") + } + } + + // URL helpers + private fun accountUrl(baseUrl: String) = "$baseUrl/v1/account" + private fun accountTokenUrl(baseUrl: String) = "$baseUrl/v1/account/token" + private fun accountSubscriptionUrl(baseUrl: String) = "$baseUrl/v1/account/subscription" + + // Auth helpers + private fun basicAuth(username: String, password: String): String { + val credentials = "$username:$password" + val encoded = android.util.Base64.encodeToString(credentials.toByteArray(), android.util.Base64.NO_WRAP) + return "Basic $encoded" + } + + private fun bearerAuth(token: String): String { + return "Bearer $token" + } + + // Request/Response data classes + data class TokenResponse( + val token: String? + ) + + data class AddSubscriptionRequest( + @SerializedName("base_url") val baseUrl: String, + val topic: String + ) + + data class UpdateSubscriptionRequest( + @SerializedName("base_url") val baseUrl: String, + val topic: String, + @SerializedName("display_name") val displayName: String? + ) + + // Exception classes + class UnauthorizedException(message: String) : Exception(message) + + companion object { + private const val TAG = "NtfyAccountApiService" + val USER_AGENT = "ntfy/${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}; Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT})" + } +} + +// Account response data classes (matching server JSON structure) +data class AccountResponse( + val username: String?, + val role: String?, + val tier: TierInfo?, + val limits: LimitsInfo?, + val stats: StatsInfo?, + val subscriptions: List?, + val reservations: List?, + val tokens: List? +) + +data class TierInfo( + val code: String?, + val name: String? +) + +data class LimitsInfo( + val basis: String?, + val messages: Long?, + val emails: Long?, + val calls: Long?, + @SerializedName("reservations") val reservationsLimit: Int?, + @SerializedName("attachment_total_size") val attachmentTotalSize: Long?, + @SerializedName("attachment_file_size") val attachmentFileSize: Long?, + @SerializedName("attachment_expiry") val attachmentExpiry: Long?, + @SerializedName("attachment_bandwidth") val attachmentBandwidth: Long? +) + +data class StatsInfo( + val messages: Long?, + @SerializedName("messages_remaining") val messagesRemaining: Long?, + val emails: Long?, + @SerializedName("emails_remaining") val emailsRemaining: Long?, + val calls: Long?, + @SerializedName("calls_remaining") val callsRemaining: Long?, + val reservations: Int?, + @SerializedName("reservations_remaining") val reservationsRemaining: Int?, + @SerializedName("attachment_total_size") val attachmentTotalSize: Long?, + @SerializedName("attachment_total_size_remaining") val attachmentTotalSizeRemaining: Long? +) + +data class RemoteSubscription( + val id: String?, + @SerializedName("base_url") val baseUrl: String, + val topic: String, + @SerializedName("display_name") val displayName: String? +) + +data class Reservation( + val topic: String, + val everyone: String? +) + +data class TokenInfo( + val token: String?, + val label: String?, + val last_access: Long?, + val last_origin: String?, + val expires: Long? +) + diff --git a/app/src/main/java/io/heckel/ntfy/msg/AccountManager.kt b/app/src/main/java/io/heckel/ntfy/msg/AccountManager.kt new file mode 100644 index 000000000..697c4859f --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/AccountManager.kt @@ -0,0 +1,268 @@ +package io.heckel.ntfy.msg + +import android.content.Context +import io.heckel.ntfy.db.Repository +import io.heckel.ntfy.db.Session +import io.heckel.ntfy.db.Subscription +import io.heckel.ntfy.util.Log +import io.heckel.ntfy.util.randomSubscriptionId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Manages ntfy account login state and subscription synchronization. + * Coordinates between Session, AccountApiService, and Repository. + */ +class AccountManager(private val context: Context) { + private val session = Session.getInstance(context) + private val repository = Repository.getInstance(context) + private val accountApi = AccountApiService(context) + + /** + * Check if user is currently logged in. + */ + fun isLoggedIn(): Boolean = session.isLoggedIn() + + /** + * Get the currently logged in username. + */ + fun getUsername(): String? = session.username() + + /** + * Get the base URL of the logged-in account. + */ + fun getBaseUrl(): String? = session.baseUrl() + + /** + * Login with username and password. + * Stores the session and triggers initial sync. + */ + suspend fun login(baseUrl: String, username: String, password: String) { + withContext(Dispatchers.IO) { + Log.d(TAG, "Logging in as $username to $baseUrl") + val token = accountApi.login(baseUrl, username, password) + session.store(username, token, baseUrl) + Log.d(TAG, "Login successful, syncing subscriptions") + syncFromRemote() + } + } + + /** + * Logout and clear the session. + */ + suspend fun logout() { + withContext(Dispatchers.IO) { + val baseUrl = session.baseUrl() + val token = session.token() + if (baseUrl != null && token != null) { + try { + accountApi.logout(baseUrl, token) + } catch (e: Exception) { + Log.w(TAG, "Logout request failed", e) + } + } + session.clear() + Log.d(TAG, "Logged out successfully") + } + } + + /** + * Extend the current token's expiration. + * Should be called periodically to keep the session alive. + */ + suspend fun extendToken() { + if (!session.isLoggedIn()) return + withContext(Dispatchers.IO) { + val baseUrl = session.baseUrl() ?: return@withContext + val token = session.token() ?: return@withContext + try { + accountApi.extendToken(baseUrl, token) + Log.d(TAG, "Token extended successfully") + } catch (e: AccountApiService.UnauthorizedException) { + Log.w(TAG, "Token expired, clearing session", e) + session.clear() + } catch (e: Exception) { + Log.w(TAG, "Failed to extend token", e) + } + } + } + + /** + * Sync subscriptions from the remote server to the local database. + * This adds remote subscriptions locally and removes local ones that don't exist remotely. + * Similar to web app's SubscriptionManager.syncFromRemote(). + */ + suspend fun syncFromRemote() { + if (!session.isLoggedIn()) return + withContext(Dispatchers.IO) { + val baseUrl = session.baseUrl() ?: return@withContext + val token = session.token() ?: return@withContext + try { + Log.d(TAG, "Syncing subscriptions from remote") + val account = accountApi.getAccount(baseUrl, token) + val remoteSubscriptions = account.subscriptions ?: emptyList() + + // Add remote subscriptions that don't exist locally + val remoteIds = mutableSetOf() + for (remote in remoteSubscriptions) { + val subscriptionId = "${remote.baseUrl}/${remote.topic}" + remoteIds.add(subscriptionId) + + val local = repository.getSubscription(remote.baseUrl, remote.topic) + if (local == null) { + // Add new subscription locally + Log.d(TAG, "Adding remote subscription: ${remote.topic} at ${remote.baseUrl}") + val subscription = Subscription( + id = randomSubscriptionId(), + baseUrl = remote.baseUrl, + topic = remote.topic, + instant = true, // Default to instant for synced subscriptions + dedicatedChannels = false, + mutedUntil = 0, + minPriority = Repository.MIN_PRIORITY_USE_GLOBAL, + autoDelete = Repository.AUTO_DELETE_USE_GLOBAL, + insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL, + lastNotificationId = null, + icon = null, + upAppId = null, + upConnectorToken = null, + displayName = remote.displayName + ) + repository.addSubscription(subscription) + } else if (remote.displayName != null && local.displayName != remote.displayName) { + // Update display name if changed + Log.d(TAG, "Updating display name for ${remote.topic}") + repository.updateSubscription(local.copy(displayName = remote.displayName)) + } + } + + // Remove local subscriptions that don't exist remotely + // Only remove non-UnifiedPush subscriptions (upAppId is null) + val localSubscriptions = repository.getSubscriptions() + for (local in localSubscriptions) { + val subscriptionId = "${local.baseUrl}/${local.topic}" + if (local.upAppId == null && !remoteIds.contains(subscriptionId)) { + Log.d(TAG, "Removing local subscription not in remote: ${local.topic}") + repository.removeSubscription(local.id) + } + } + + Log.d(TAG, "Sync completed: ${remoteSubscriptions.size} remote subscriptions") + } catch (e: AccountApiService.UnauthorizedException) { + Log.w(TAG, "Token expired during sync, clearing session", e) + session.clear() + } catch (e: Exception) { + Log.w(TAG, "Failed to sync subscriptions", e) + } + } + } + + /** + * Add a subscription to the remote server. + * Called when a new subscription is added locally. + */ + suspend fun addSubscriptionToRemote(subscription: Subscription) { + if (!session.isLoggedIn()) return + // Don't sync UnifiedPush subscriptions + if (subscription.upAppId != null) return + + withContext(Dispatchers.IO) { + val baseUrl = session.baseUrl() ?: return@withContext + val token = session.token() ?: return@withContext + try { + Log.d(TAG, "Adding subscription to remote: ${subscription.topic}") + accountApi.addSubscription(baseUrl, token, subscription.baseUrl, subscription.topic) + } catch (e: AccountApiService.UnauthorizedException) { + Log.w(TAG, "Token expired, clearing session", e) + session.clear() + } catch (e: Exception) { + Log.w(TAG, "Failed to add subscription to remote", e) + } + } + } + + /** + * Update a subscription on the remote server. + * Called when a subscription is updated locally. + */ + suspend fun updateSubscriptionOnRemote(subscription: Subscription) { + if (!session.isLoggedIn()) return + // Don't sync UnifiedPush subscriptions + if (subscription.upAppId != null) return + + withContext(Dispatchers.IO) { + val baseUrl = session.baseUrl() ?: return@withContext + val token = session.token() ?: return@withContext + try { + Log.d(TAG, "Updating subscription on remote: ${subscription.topic}") + accountApi.updateSubscription( + baseUrl, + token, + subscription.baseUrl, + subscription.topic, + subscription.displayName + ) + } catch (e: AccountApiService.UnauthorizedException) { + Log.w(TAG, "Token expired, clearing session", e) + session.clear() + } catch (e: Exception) { + Log.w(TAG, "Failed to update subscription on remote", e) + } + } + } + + /** + * Delete a subscription from the remote server. + * Called when a subscription is removed locally. + */ + suspend fun deleteSubscriptionFromRemote(subscriptionBaseUrl: String, topic: String) { + if (!session.isLoggedIn()) return + + withContext(Dispatchers.IO) { + val baseUrl = session.baseUrl() ?: return@withContext + val token = session.token() ?: return@withContext + try { + Log.d(TAG, "Deleting subscription from remote: $topic") + accountApi.deleteSubscription(baseUrl, token, subscriptionBaseUrl, topic) + } catch (e: AccountApiService.UnauthorizedException) { + Log.w(TAG, "Token expired, clearing session", e) + session.clear() + } catch (e: Exception) { + Log.w(TAG, "Failed to delete subscription from remote", e) + } + } + } + + /** + * Get account information. + */ + suspend fun getAccountInfo(): AccountResponse? { + if (!session.isLoggedIn()) return null + return withContext(Dispatchers.IO) { + val baseUrl = session.baseUrl() ?: return@withContext null + val token = session.token() ?: return@withContext null + try { + accountApi.getAccount(baseUrl, token) + } catch (e: Exception) { + Log.w(TAG, "Failed to get account info", e) + null + } + } + } + + companion object { + private const val TAG = "NtfyAccountManager" + + @Volatile + private var instance: AccountManager? = null + + fun getInstance(context: Context): AccountManager { + return instance ?: synchronized(this) { + val newInstance = instance ?: AccountManager(context.applicationContext) + instance = newInstance + newInstance + } + } + } +} + diff --git a/app/src/main/java/io/heckel/ntfy/ui/AccountFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AccountFragment.kt new file mode 100644 index 000000000..2f9d0ec06 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/AccountFragment.kt @@ -0,0 +1,258 @@ +package io.heckel.ntfy.ui + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.Button +import android.widget.ProgressBar +import android.widget.TextView +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import io.heckel.ntfy.R +import io.heckel.ntfy.msg.AccountManager +import io.heckel.ntfy.util.AfterChangedTextWatcher +import io.heckel.ntfy.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class AccountFragment : DialogFragment() { + private lateinit var accountManager: AccountManager + private lateinit var listener: AccountDialogListener + private lateinit var appBaseUrl: String + + private lateinit var toolbar: MaterialToolbar + private lateinit var loginView: View + private lateinit var loggedInView: View + + // Login view + private lateinit var serverUrlText: TextInputEditText + private lateinit var usernameText: TextInputEditText + private lateinit var passwordText: TextInputEditText + private lateinit var loginButton: Button + private lateinit var loginProgress: ProgressBar + private lateinit var loginErrorText: TextView + + // Logged in view + private lateinit var loggedInUserText: TextView + private lateinit var loggedInServerText: TextView + private lateinit var syncButton: Button + private lateinit var logoutButton: Button + private lateinit var syncProgress: ProgressBar + + interface AccountDialogListener { + fun onAccountChanged() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + listener = activity as AccountDialogListener + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + if (activity == null) { + throw IllegalStateException("Activity cannot be null") + } + + accountManager = AccountManager.getInstance(requireContext()) + appBaseUrl = getString(R.string.app_base_url) + + val view = requireActivity().layoutInflater.inflate(R.layout.fragment_account_dialog, null) + + // Setup toolbar + toolbar = view.findViewById(R.id.account_dialog_toolbar) + toolbar.setNavigationOnClickListener { + dismiss() + } + + // Main views + loginView = view.findViewById(R.id.account_dialog_login_view) + loggedInView = view.findViewById(R.id.account_dialog_logged_in_view) + + // Login view elements + serverUrlText = view.findViewById(R.id.account_dialog_server_url) + usernameText = view.findViewById(R.id.account_dialog_username) + passwordText = view.findViewById(R.id.account_dialog_password) + loginButton = view.findViewById(R.id.account_dialog_login_button) + loginProgress = view.findViewById(R.id.account_dialog_login_progress) + loginErrorText = view.findViewById(R.id.account_dialog_login_error) + + // Logged in view elements + loggedInUserText = view.findViewById(R.id.account_dialog_logged_in_user) + loggedInServerText = view.findViewById(R.id.account_dialog_logged_in_server) + syncButton = view.findViewById(R.id.account_dialog_sync_button) + logoutButton = view.findViewById(R.id.account_dialog_logout_button) + syncProgress = view.findViewById(R.id.account_dialog_sync_progress) + + // Set default server URL + serverUrlText.setText(appBaseUrl) + + // Login button validation + val textWatcher = AfterChangedTextWatcher { + validateLoginForm() + } + serverUrlText.addTextChangedListener(textWatcher) + usernameText.addTextChangedListener(textWatcher) + passwordText.addTextChangedListener(textWatcher) + + // Login button click + loginButton.setOnClickListener { + performLogin() + } + + // Sync button click + syncButton.setOnClickListener { + performSync() + } + + // Logout button click + logoutButton.setOnClickListener { + showLogoutConfirmation() + } + + // Build dialog + val dialog = Dialog(requireContext(), R.style.Theme_App_FullScreenDialog) + dialog.setContentView(view) + + // Show appropriate view + updateView() + + return dialog + } + + override fun onStart() { + super.onStart() + dialog?.window?.apply { + setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + } + + override fun onResume() { + super.onResume() + if (!accountManager.isLoggedIn()) { + usernameText.postDelayed({ + usernameText.requestFocus() + val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(usernameText, InputMethodManager.SHOW_IMPLICIT) + }, 200) + } + } + + private fun updateView() { + if (accountManager.isLoggedIn()) { + toolbar.setTitle(R.string.settings_account_title) + loginView.visibility = View.GONE + loggedInView.visibility = View.VISIBLE + loggedInUserText.text = accountManager.getUsername() + loggedInServerText.text = accountManager.getBaseUrl() + } else { + toolbar.setTitle(R.string.settings_account_login_title) + loginView.visibility = View.VISIBLE + loggedInView.visibility = View.GONE + } + } + + private fun validateLoginForm() { + val serverUrl = serverUrlText.text.toString() + val username = usernameText.text.toString() + val password = passwordText.text.toString() + loginButton.isEnabled = serverUrl.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty() + } + + private fun performLogin() { + val serverUrl = serverUrlText.text.toString().trimEnd('/') + val username = usernameText.text.toString() + val password = passwordText.text.toString() + + loginProgress.visibility = View.VISIBLE + loginErrorText.visibility = View.GONE + enableLoginForm(false) + + lifecycleScope.launch(Dispatchers.IO) { + try { + accountManager.login(serverUrl, username, password) + activity?.runOnUiThread { + Log.d(TAG, "Login successful") + listener.onAccountChanged() + dismiss() + } + } catch (e: Exception) { + Log.w(TAG, "Login failed", e) + activity?.runOnUiThread { + loginProgress.visibility = View.GONE + loginErrorText.visibility = View.VISIBLE + loginErrorText.text = getString(R.string.settings_account_login_failed, e.message) + enableLoginForm(true) + } + } + } + } + + private fun enableLoginForm(enable: Boolean) { + serverUrlText.isEnabled = enable + usernameText.isEnabled = enable + passwordText.isEnabled = enable + loginButton.isEnabled = enable + } + + private fun performSync() { + syncProgress.visibility = View.VISIBLE + syncButton.isEnabled = false + + lifecycleScope.launch(Dispatchers.IO) { + try { + accountManager.syncFromRemote() + activity?.runOnUiThread { + syncProgress.visibility = View.GONE + syncButton.isEnabled = true + listener.onAccountChanged() + } + } catch (e: Exception) { + Log.w(TAG, "Sync failed", e) + activity?.runOnUiThread { + syncProgress.visibility = View.GONE + syncButton.isEnabled = true + } + } + } + } + + private fun showLogoutConfirmation() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.settings_account_logout_title) + .setMessage(R.string.settings_account_logout_confirmation) + .setPositiveButton(R.string.settings_account_logout_button) { _, _ -> + performLogout() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun performLogout() { + lifecycleScope.launch(Dispatchers.IO) { + accountManager.logout() + activity?.runOnUiThread { + Log.d(TAG, "Logout successful") + listener.onAccountChanged() + updateView() + } + } + } + + companion object { + const val TAG = "NtfyAccountFragment" + + fun newInstance(): AccountFragment { + return AccountFragment() + } + } +} + diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 6c4d369ef..1756c84b6 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -34,6 +34,7 @@ import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.firebase.FirebaseMessenger +import io.heckel.ntfy.msg.AccountManager import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.service.SubscriberServiceManager @@ -221,6 +222,8 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet lastActive = Date().time/1000 ) repository.addSubscription(subscription) + // Sync to remote account if logged in + AccountManager.getInstance(this@DetailActivity).addSubscriptionToRemote(subscription) // Subscribe to Firebase topic if ntfy.sh (even if instant, just to be sure!) if (baseUrl == appBaseUrl) { @@ -847,6 +850,8 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet GlobalScope.launch(Dispatchers.IO) { repository.removeAllNotifications(subscriptionId) repository.removeSubscription(subscriptionId) + // Sync deletion to remote account if logged in + AccountManager.getInstance(this@DetailActivity).deleteSubscriptionFromRemote(subscriptionBaseUrl, subscriptionTopic) if (subscriptionBaseUrl == appBaseUrl) { messenger.unsubscribe(subscriptionTopic) } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt index f0293c79c..e3cdfe905 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt @@ -22,6 +22,7 @@ import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.R import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Subscription +import io.heckel.ntfy.msg.AccountManager import io.heckel.ntfy.msg.DownloadAttachmentWorker import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.service.SubscriberServiceManager @@ -397,7 +398,7 @@ class DetailSettingsActivity : AppCompatActivity() { override fun putString(key: String?, value: String?) { val displayName = if (value != "") value else null val newSubscription = subscription.copy(displayName = displayName) - save(newSubscription) + save(newSubscription, syncDisplayName = true) // Update activity title activity?.runOnUiThread { activity?.title = displayName(appBaseUrl, newSubscription) @@ -505,13 +506,17 @@ class DetailSettingsActivity : AppCompatActivity() { } } - private fun save(newSubscription: Subscription, refresh: Boolean = false) { + private fun save(newSubscription: Subscription, refresh: Boolean = false, syncDisplayName: Boolean = false) { subscription = newSubscription lifecycleScope.launch(Dispatchers.IO) { repository.updateSubscription(newSubscription) if (refresh) { SubscriberServiceManager.refresh(requireContext()) } + if (syncDisplayName) { + // Sync displayName change to remote account if logged in + AccountManager.getInstance(requireContext()).updateSubscriptionOnRemote(newSubscription) + } } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 8c8c10202..9d871ff9c 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -66,6 +66,8 @@ import io.heckel.ntfy.util.maybeSplitTopicUrl import io.heckel.ntfy.util.randomSubscriptionId import io.heckel.ntfy.util.shortUrl import io.heckel.ntfy.util.topicShortUrl +import io.heckel.ntfy.msg.AccountManager +import io.heckel.ntfy.work.AccountSyncWorker import io.heckel.ntfy.work.DeleteWorker import io.heckel.ntfy.work.PollWorker import kotlinx.coroutines.Dispatchers @@ -353,6 +355,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific schedulePeriodicPollWorker() schedulePeriodicServiceRestartWorker() schedulePeriodicDeleteWorker() + schedulePeriodicAccountSyncWorker() // Permissions maybeRequestNotificationPermission() @@ -477,6 +480,28 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific workManager?.enqueueUniquePeriodicWork(SubscriberService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work) } + private fun schedulePeriodicAccountSyncWorker() { + val workerVersion = repository.getAccountSyncWorkerVersion() + val workPolicy = if (workerVersion == AccountSyncWorker.VERSION) { + Log.d(TAG, "AccountSyncWorker version matches: choosing KEEP as existing work policy") + ExistingPeriodicWorkPolicy.KEEP + } else { + Log.d(TAG, "AccountSyncWorker version DOES NOT MATCH: choosing REPLACE as existing work policy") + repository.setAccountSyncWorkerVersion(AccountSyncWorker.VERSION) + ExistingPeriodicWorkPolicy.REPLACE + } + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val work = PeriodicWorkRequestBuilder(ACCOUNT_SYNC_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES) + .setConstraints(constraints) + .addTag(AccountSyncWorker.TAG) + .addTag(AccountSyncWorker.WORK_NAME_PERIODIC) + .build() + Log.d(TAG, "AccountSyncWorker: Scheduling period work every $ACCOUNT_SYNC_WORKER_INTERVAL_MINUTES minutes") + workManager?.enqueueUniquePeriodicWork(AccountSyncWorker.WORK_NAME_PERIODIC, workPolicy, work) + } + override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_main_action_bar, menu) this.menu = menu @@ -652,7 +677,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific newCount = 0, lastActive = Date().time/1000 ) - viewModel.add(subscription) + viewModel.add(this, subscription) // Subscribe to Firebase topic if ntfy.sh (even if instant, just to be sure!) if (baseUrl == appBaseUrl) { @@ -698,6 +723,13 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific private fun refreshAllSubscriptions() { lifecycleScope.launch(Dispatchers.IO) { + // Sync account subscriptions if logged in + try { + AccountManager.getInstance(this@MainActivity).syncFromRemote() + } catch (e: Exception) { + Log.w(TAG, "Account sync failed during refresh", e) + } + Log.d(TAG, "Polling for new notifications") var errors = 0 var errorMessage = "" // First error @@ -856,5 +888,6 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific const val POLL_WORKER_INTERVAL_MINUTES = 60L const val DELETE_WORKER_INTERVAL_MINUTES = 8 * 60L const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L + const val ACCOUNT_SYNC_WORKER_INTERVAL_MINUTES = 15L } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt index 60ddb55b4..ef47f95f5 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import io.heckel.ntfy.R import io.heckel.ntfy.db.* import io.heckel.ntfy.firebase.FirebaseMessenger +import io.heckel.ntfy.msg.AccountManager import io.heckel.ntfy.up.Distributor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -22,8 +23,10 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() { return repository.getSubscriptionIdsWithInstantStatusLiveData() } - fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) { + fun add(context: Context, subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) { repository.addSubscription(subscription) + // Sync to remote account if logged in + AccountManager.getInstance(context).addSubscriptionToRemote(subscription) } fun remove(context: Context, subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) { @@ -34,6 +37,8 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() { } repository.removeAllNotifications(subscriptionId) repository.removeSubscription(subscriptionId) + // Sync deletion to remote account if logged in + AccountManager.getInstance(context).deleteSubscriptionFromRemote(subscription.baseUrl, subscription.topic) if (subscription.icon != null) { val resolver = context.applicationContext.contentResolver try { diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index d916d7b91..94ad5374e 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -34,6 +34,7 @@ import io.heckel.ntfy.R import io.heckel.ntfy.backup.Backuper import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.User +import io.heckel.ntfy.msg.AccountManager import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.util.* import kotlinx.coroutines.Dispatchers @@ -52,7 +53,7 @@ import java.util.concurrent.TimeUnit * https://github.com/googlearchive/android-preferences/blob/master/app/src/main/java/com/example/androidx/preference/sample/MainActivity.kt */ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, - UserFragment.UserDialogListener, CustomHeaderFragment.CustomHeaderDialogListener { + UserFragment.UserDialogListener, CustomHeaderFragment.CustomHeaderDialogListener, AccountFragment.AccountDialogListener { private lateinit var settingsFragment: SettingsFragment private lateinit var userSettingsFragment: UserSettingsFragment private lateinit var customHeaderSettingsFragment: CustomHeaderSettingsFragment @@ -175,6 +176,9 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere // preferenceDataStore is overridden to use the repository. This is convenient, because // everybody has access to the repository. + // Account + setupAccountPreference() + // Notifications muted until (global) val mutedUntilPrefId = context?.getString(R.string.settings_notifications_muted_until_key) ?: return val mutedUntil: ListPreference? = findPreference(mutedUntilPrefId) @@ -737,6 +741,32 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere repository.setAutoDownloadMaxSize(autoDownloadSelectionCopy) } + private fun setupAccountPreference() { + val accountPrefId = context?.getString(R.string.settings_account_key) ?: return + val accountPref: Preference? = findPreference(accountPrefId) + accountPref?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store + accountPref?.onPreferenceClickListener = Preference.OnPreferenceClickListener { + activity?.let { activity -> + AccountFragment + .newInstance() + .show(activity.supportFragmentManager, AccountFragment.TAG) + } + true + } + updateAccountPreference() + } + + fun updateAccountPreference() { + val accountPrefId = context?.getString(R.string.settings_account_key) ?: return + val accountPref: Preference? = findPreference(accountPrefId) + val accountManager = AccountManager.getInstance(requireContext()) + if (accountManager.isLoggedIn()) { + accountPref?.summary = getString(R.string.settings_account_summary_logged_in, accountManager.getUsername()) + } else { + accountPref?.summary = getString(R.string.settings_account_summary_not_logged_in) + } + } + private fun restartService() { serviceManager.restart() // Service will auto-restart } @@ -1109,6 +1139,12 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere settingsFragment.setAutoDownload() } + override fun onAccountChanged() { + if (this::settingsFragment.isInitialized) { + settingsFragment.updateAccountPreference() + } + } + companion object { private const val TAG = "NtfySettingsActivity" private const val TITLE_TAG = "title" diff --git a/app/src/main/java/io/heckel/ntfy/work/AccountSyncWorker.kt b/app/src/main/java/io/heckel/ntfy/work/AccountSyncWorker.kt new file mode 100644 index 000000000..c2a885277 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/work/AccountSyncWorker.kt @@ -0,0 +1,61 @@ +package io.heckel.ntfy.work + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import io.heckel.ntfy.BuildConfig +import io.heckel.ntfy.msg.AccountManager +import io.heckel.ntfy.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Background worker for periodic account synchronization. + * Extends the auth token and syncs subscriptions from the server. + */ +class AccountSyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { + // IMPORTANT: + // Every time the worker is changed, the periodic work has to be REPLACEd. + // This is facilitated in the MainActivity using the VERSION below. + + init { + Log.init(ctx) // Init in all entrypoints + } + + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + val accountManager = AccountManager.getInstance(applicationContext) + + if (!accountManager.isLoggedIn()) { + Log.d(TAG, "Not logged in, skipping account sync") + return@withContext Result.success() + } + + Log.d(TAG, "Running account sync worker") + + // Extend token to keep session alive + try { + accountManager.extendToken() + } catch (e: Exception) { + Log.e(TAG, "Failed to extend token: ${e.message}", e) + } + + // Sync subscriptions from remote + try { + accountManager.syncFromRemote() + } catch (e: Exception) { + Log.e(TAG, "Failed to sync subscriptions: ${e.message}", e) + } + + Log.d(TAG, "Account sync worker completed") + return@withContext Result.success() + } + } + + companion object { + const val VERSION = BuildConfig.VERSION_CODE + const val TAG = "NtfyAccountSyncWorker" + const val WORK_NAME_PERIODIC = "NtfyAccountSyncWorkerPeriodic" // Do not change + } +} + diff --git a/app/src/main/res/layout/fragment_account_dialog.xml b/app/src/main/res/layout/fragment_account_dialog.xml new file mode 100644 index 000000000..576f08f38 --- /dev/null +++ b/app/src/main/res/layout/fragment_account_dialog.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +