Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/src/main/java/io/heckel/ntfy/db/Repository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,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 {
Expand Down Expand Up @@ -593,6 +603,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"
Expand Down
69 changes: 69 additions & 0 deletions app/src/main/java/io/heckel/ntfy/db/Session.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}

297 changes: 297 additions & 0 deletions app/src/main/java/io/heckel/ntfy/msg/AccountApiService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
package io.heckel.ntfy.msg

import android.content.Context
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import io.heckel.ntfy.util.HttpUtil
import io.heckel.ntfy.util.Log
import okhttp3.RequestBody.Companion.toRequestBody

/**
* 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()

/**
* Login with username/password to get a bearer token.
* POST /v1/account/token
*/
suspend fun login(baseUrl: String, username: String, password: String): String {
val url = accountTokenUrl(baseUrl)
Log.d(TAG, "Logging in to $url as $username")
val client = HttpUtil.defaultClient(context, baseUrl)
val request = HttpUtil.requestBuilder(url)
.post("".toRequestBody())
.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
*/
suspend fun logout(baseUrl: String, token: String) {
val url = accountTokenUrl(baseUrl)
Log.d(TAG, "Logging out from $url")
val client = HttpUtil.defaultClient(context, baseUrl)
val request = HttpUtil.requestBuilder(url)
.delete()
.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
*/
suspend fun extendToken(baseUrl: String, token: String) {
val url = accountTokenUrl(baseUrl)
Log.d(TAG, "Extending token at $url")
val client = HttpUtil.defaultClient(context, baseUrl)
val request = HttpUtil.requestBuilder(url)
.patch("".toRequestBody())
.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
*/
suspend fun getAccount(baseUrl: String, token: String): AccountResponse {
val url = accountUrl(baseUrl)
Log.d(TAG, "Fetching account from $url")
val client = HttpUtil.defaultClient(context, baseUrl)
val request = HttpUtil.requestBuilder(url)
.get()
.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
*/
suspend 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 client = HttpUtil.defaultClient(context, baseUrl)
val request = HttpUtil.requestBuilder(url)
.post(payload.toRequestBody())
.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
*/
suspend 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 client = HttpUtil.defaultClient(context, baseUrl)
val request = HttpUtil.requestBuilder(url)
.patch(payload.toRequestBody())
.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
*/
suspend fun deleteSubscription(baseUrl: String, token: String, subscriptionBaseUrl: String, topic: String) {
val url = accountSubscriptionUrl(baseUrl)
Log.d(TAG, "Deleting subscription $topic from $url")
val client = HttpUtil.defaultClient(context, baseUrl)
val request = HttpUtil.requestBuilder(url)
.delete()
.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"
}
}

// 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<RemoteSubscription>?,
val reservations: List<Reservation>?,
val tokens: List<TokenInfo>?
)

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?
)
Loading