diff --git a/app/schemas/io.heckel.ntfy.db.Database/7.json b/app/schemas/io.heckel.ntfy.db.Database/7.json index 496dea3..0e09a5a 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/7.json +++ b/app/schemas/io.heckel.ntfy.db.Database/7.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 7, - "identityHash": "f8551fac095d795a83ba7a95277e3f0f", + "identityHash": "eda2cb9740c4542f24462779eb6ff81d", "entities": [ { "tableName": "Subscription", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `authUserId` INTEGER, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -38,12 +38,6 @@ "affinity": "INTEGER", "notNull": true }, - { - "fieldPath": "authUserId", - "columnName": "authUserId", - "affinity": "INTEGER", - "notNull": false - }, { "fieldPath": "upAppId", "columnName": "upAppId", @@ -206,14 +200,8 @@ }, { "tableName": "User", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))", "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, { "fieldPath": "baseUrl", "columnName": "baseUrl", @@ -235,9 +223,9 @@ ], "primaryKey": { "columnNames": [ - "id" + "baseUrl" ], - "autoGenerate": true + "autoGenerate": false }, "indices": [], "foreignKeys": [] @@ -296,7 +284,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f8551fac095d795a83ba7a95277e3f0f')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eda2cb9740c4542f24462779eb6ff81d')" ] } } \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index 28b0aff..6625d92 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -14,7 +14,6 @@ data class Subscription( @ColumnInfo(name = "topic") val topic: String, @ColumnInfo(name = "instant") val instant: Boolean, @ColumnInfo(name = "mutedUntil") val mutedUntil: Long, // TODO notificationSound, notificationSchedule - @ColumnInfo(name = "authUserId") val authUserId: Long?, @ColumnInfo(name = "upAppId") val upAppId: String?, // UnifiedPush application package name @ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token // TODO autoDownloadAttachments, minPriority @@ -23,8 +22,8 @@ data class Subscription( @Ignore val lastActive: Long = 0, // Unix timestamp @Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE ) { - constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, authUserId: Long?, upAppId: String, upConnectorToken: String) : - this(id, baseUrl, topic, instant, mutedUntil, authUserId, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE) + constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, upAppId: String, upConnectorToken: String) : + this(id, baseUrl, topic, instant, mutedUntil, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE) } enum class ConnectionState { @@ -37,7 +36,6 @@ data class SubscriptionWithMetadata( val topic: String, val instant: Boolean, val mutedUntil: Long, - val authUserId: Long?, val upAppId: String?, val upConnectorToken: String?, val totalCount: Int, @@ -82,8 +80,7 @@ const val PROGRESS_DONE = 100 @Entity data class User( - @PrimaryKey(autoGenerate = true) val id: Long, - @ColumnInfo(name = "baseUrl") val baseUrl: String, + @PrimaryKey @ColumnInfo(name = "baseUrl") val baseUrl: String, @ColumnInfo(name = "username") val username: String, @ColumnInfo(name = "password") val password: String ) { @@ -194,7 +191,7 @@ abstract class Database : RoomDatabase() { interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.authUserId, s.upAppId, s.upConnectorToken, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -207,7 +204,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.authUserId, s.upAppId, s.upConnectorToken, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -220,7 +217,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.authUserId, s.upAppId, s.upConnectorToken, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -233,7 +230,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.authUserId, s.upAppId, s.upConnectorToken, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -246,7 +243,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.authUserId, s.upAppId, s.upConnectorToken, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -263,14 +260,8 @@ interface SubscriptionDao { @Update fun update(subscription: Subscription) - @Query("UPDATE subscription SET authUserId = :authUserId WHERE id = :subscriptionId") - fun updateSubscriptionAuthUserId(subscriptionId: Long, authUserId: Long?) - @Query("DELETE FROM subscription WHERE id = :subscriptionId") fun remove(subscriptionId: Long) - - @Query("UPDATE subscription SET authUserId = null WHERE authUserId = :authUserId") - fun removeAuthUserFromSubscriptions(authUserId: Long) } @Dao @@ -311,14 +302,14 @@ interface UserDao { @Query("SELECT * FROM user ORDER BY username") suspend fun list(): List - @Query("SELECT * FROM user WHERE id = :id") - suspend fun get(id: Long): User + @Query("SELECT * FROM user WHERE baseUrl = :baseUrl") + suspend fun get(baseUrl: String): User? @Update suspend fun update(user: User) - @Query("DELETE FROM user WHERE id = :id") - suspend fun delete(id: Long) + @Query("DELETE FROM user WHERE baseUrl = :baseUrl") + suspend fun delete(baseUrl: String) } @Dao 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 b4d1c36..9c5e3db 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -78,20 +78,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas subscriptionDao.update(subscription) } - fun updateSubscriptionAuthUserId(subscriptionId: Long, authUserId: Long?) { - subscriptionDao.updateSubscriptionAuthUserId(subscriptionId, authUserId) - } - @Suppress("RedundantSuspendModifier") @WorkerThread suspend fun removeSubscription(subscriptionId: Long) { subscriptionDao.remove(subscriptionId) } - fun removeAuthUserFromSubscriptions(authUserId: Long) { - subscriptionDao.removeAuthUserFromSubscriptions(authUserId) - } - fun getNotificationsLiveData(subscriptionId: Long): LiveData> { return notificationDao.listFlow(subscriptionId).asLiveData() } @@ -152,12 +144,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas userDao.update(user) } - suspend fun getUser(userId: Long): User { - return userDao.get(userId) + suspend fun getUser(baseUrl: String): User? { + return userDao.get(baseUrl) } - suspend fun deleteUser(userId: Long) { - userDao.delete(userId) + suspend fun deleteUser(baseUrl: String) { + userDao.delete(baseUrl) } fun getPollWorkerVersion(): Int { @@ -346,7 +338,6 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas topic = s.topic, instant = s.instant, mutedUntil = s.mutedUntil, - authUserId = s.authUserId, upAppId = s.upAppId, upConnectorToken = s.upConnectorToken, totalCount = s.totalCount, @@ -367,7 +358,6 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas topic = s.topic, instant = s.instant, mutedUntil = s.mutedUntil, - authUserId = s.authUserId, upAppId = s.upAppId, upConnectorToken = s.upConnectorToken, totalCount = s.totalCount, diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index 6793c98..a056ce5 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -12,7 +12,6 @@ import io.heckel.ntfy.util.topicUrlJsonPoll import okhttp3.* import okhttp3.RequestBody.Companion.toRequestBody import java.io.IOException -import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets.UTF_8 import java.util.concurrent.TimeUnit import kotlin.random.Random @@ -134,30 +133,26 @@ class ApiService { return call } - fun checkAnonTopicRead(baseUrl: String, topic: String): Boolean { - return checkTopicRead(baseUrl, topic, creds = null) - } - - fun checkUserTopicRead(baseUrl: String, topic: String, username: String, password: String): Boolean { - Log.d(TAG, "Authorizing user $username against ${topicUrl(baseUrl, topic)}") - return checkTopicRead(baseUrl, topic, creds = Credentials.basic(username, password, UTF_8)) - } - - private fun checkTopicRead(baseUrl: String, topic: String, creds: String?): Boolean { + fun authTopicRead(baseUrl: String, topic: String, user: User?): Boolean { + if (user == null) { + Log.d(TAG, "Checking anonymous read against ${topicUrl(baseUrl, topic)}") + } else { + Log.d(TAG, "Checking read access for user ${user.username} against ${topicUrl(baseUrl, topic)}") + } val url = topicUrlAuth(baseUrl, topic) val builder = Request.Builder() .get() .url(url) .addHeader("User-Agent", USER_AGENT) - if (creds != null) { - builder.addHeader("Authorization", creds) + if (user != null) { + builder.addHeader("Authorization", Credentials.basic(user.username, user.password, UTF_8)) } val request = builder.build() client.newCall(request).execute().use { response -> - if (creds == null) { - return response.isSuccessful || response.code == 404 // Treat 404 as success (old server; to be removed in future versions) + return if (user == null) { + response.isSuccessful || response.code == 404 // Treat 404 as success (old server; to be removed in future versions) } else { - return response.isSuccessful + response.isSuccessful } } } diff --git a/app/src/main/java/io/heckel/ntfy/service/Connection.kt b/app/src/main/java/io/heckel/ntfy/service/Connection.kt index cb9f22e..a8dfed1 100644 --- a/app/src/main/java/io/heckel/ntfy/service/Connection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/Connection.kt @@ -8,6 +8,5 @@ interface Connection { data class ConnectionId( val baseUrl: String, - val authUserId: Long?, val topicsToSubscriptionIds: Map ) diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 753fde0..14e4095 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -155,7 +155,7 @@ class SubscriberService : Service() { .filter { s -> s.instant } val activeConnectionIds = connections.keys().toList().toSet() val desiredConnectionIds = instantSubscriptions // Set - .groupBy { s -> ConnectionId(s.baseUrl, s.authUserId, emptyMap()) } + .groupBy { s -> ConnectionId(s.baseUrl, emptyMap()) } .map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }) } .toSet() val newConnectionIds = desiredConnectionIds subtract activeConnectionIds @@ -183,11 +183,7 @@ class SubscriberService : Service() { val since = System.currentTimeMillis()/1000 val serviceActive = { -> isServiceStarted } - val user = if (connectionId.authUserId != null) { - repository.getUser(connectionId.authUserId) - } else { - null - } + val user = repository.getUser(connectionId.baseUrl) val connection = if (repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS) { val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager WsConnection(connectionId, repository, user, since, ::onStateChanged, ::onNotificationReceived, alarmManager) diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt index 8b5b97f..4113df6 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt @@ -24,7 +24,7 @@ class SubscriberServiceManager(private val context: Context) { workManager.enqueue(startServiceRequest) } - fun stop() { + fun restart() { Intent(context, SubscriberService::class.java).also { intent -> context.stopService(intent) // Service will auto-restart } diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt index a901d31..419c298 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt @@ -24,7 +24,6 @@ import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.util.topicUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.random.Random class AddFragment : DialogFragment() { private val api = ApiService() @@ -51,7 +50,6 @@ class AddFragment : DialogFragment() { // Login page private lateinit var users: List - private lateinit var loginUsersSpinner: Spinner private lateinit var loginUsernameText: TextInputEditText private lateinit var loginPasswordText: TextInputEditText private lateinit var loginProgress: ProgressBar @@ -60,7 +58,7 @@ class AddFragment : DialogFragment() { private lateinit var baseUrls: List // List of base URLs already used, excluding app_base_url interface SubscribeListener { - fun onSubscribe(topic: String, baseUrl: String, instant: Boolean, authUserId: Long?) + fun onSubscribe(topic: String, baseUrl: String, instant: Boolean) } override fun onAttach(context: Context) { @@ -98,7 +96,6 @@ class AddFragment : DialogFragment() { subscribeErrorImage = view.findViewById(R.id.add_dialog_error_image) // Fields for "login page" - loginUsersSpinner = view.findViewById(R.id.add_dialog_login_users_spinner) loginUsernameText = view.findViewById(R.id.add_dialog_login_username) loginPasswordText = view.findViewById(R.id.add_dialog_login_password) loginProgress = view.findViewById(R.id.add_dialog_login_progress) @@ -178,31 +175,6 @@ class AddFragment : DialogFragment() { // Show/hide based on flavor subscribeInstantDeliveryBox.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE - // Show/hide drop-down and username/password fields - loginUsersSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - if (position == 0) { - loginUsernameText.visibility = View.VISIBLE - loginUsernameText.isEnabled = true - loginPasswordText.visibility = View.VISIBLE - loginPasswordText.isEnabled = true - if (loginUsernameText.requestFocus()) { - val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(loginUsernameText, InputMethodManager.SHOW_IMPLICIT) - } - } else { - loginUsernameText.visibility = View.GONE - loginUsernameText.isEnabled = false - loginPasswordText.visibility = View.GONE - loginPasswordText.isEnabled = false - } - validateInputLoginView() - } - override fun onNothingSelected(parent: AdapterView<*>?) { - // This should not happen, ha! - } - } - // Username/password validation on type val textWatcher = object : TextWatcher { override fun afterTextChanged(s: Editable?) { @@ -288,97 +260,94 @@ class AddFragment : DialogFragment() { val topic = subscribeTopicText.text.toString() val baseUrl = getBaseUrl() if (subscribeView.visibility == View.VISIBLE) { - checkAnonReadAndMaybeShowLogin(baseUrl, topic) + checkReadAndMaybeShowLogin(baseUrl, topic) } else if (loginView.visibility == View.VISIBLE) { loginAndMaybeDismiss(baseUrl, topic) } } - private fun checkAnonReadAndMaybeShowLogin(baseUrl: String, topic: String) { + private fun checkReadAndMaybeShowLogin(baseUrl: String, topic: String) { subscribeProgress.visibility = View.VISIBLE subscribeErrorImage.visibility = View.GONE enableSubscribeView(false) lifecycleScope.launch(Dispatchers.IO) { - Log.d(TAG, "Checking anonymous read access to topic ${topicUrl(baseUrl, topic)}") try { - val authorized = api.checkAnonTopicRead(baseUrl, topic) + val user = repository.getUser(baseUrl) // May be null + val authorized = api.authTopicRead(baseUrl, topic, user) if (authorized) { - Log.d(TAG, "Anonymous access granted to topic ${topicUrl(baseUrl, topic)}") - dismiss(authUserId = null) + Log.d(TAG, "Access granted to topic ${topicUrl(baseUrl, topic)}") + dismissDialog() } else { - Log.w(TAG, "Anonymous access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog") - val activity = activity ?: return@launch // We may have pressed "Cancel" - activity.runOnUiThread { - showLoginView(activity, baseUrl) + if (user != null) { + Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, but user already exists") + showToastAndReenableSubscribeView(getString(R.string.add_dialog_login_error_not_authorized)) + } else { + Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog") + val activity = activity ?: return@launch // We may have pressed "Cancel" + activity.runOnUiThread { + showLoginView(activity, baseUrl) + } } } } catch (e: Exception) { Log.w(TAG, "Connection to topic failed: ${e.message}", e) - val activity = activity ?: return@launch // We may have pressed "Cancel" - activity.runOnUiThread { - subscribeProgress.visibility = View.GONE - subscribeErrorImage.visibility = View.VISIBLE - enableSubscribeView(true) - Toast - .makeText(context, getString(R.string.add_dialog_error_connection_failed, e.message), Toast.LENGTH_LONG) - .show() - } + showToastAndReenableSubscribeView(e.message) } } } + private fun showToastAndReenableSubscribeView(message: String?) { + val activity = activity ?: return // We may have pressed "Cancel" + activity.runOnUiThread { + subscribeProgress.visibility = View.GONE + subscribeErrorImage.visibility = View.VISIBLE + enableSubscribeView(true) + Toast + .makeText(context, message, Toast.LENGTH_LONG) + .show() + } + } + private fun loginAndMaybeDismiss(baseUrl: String, topic: String) { loginProgress.visibility = View.VISIBLE loginErrorImage.visibility = View.GONE enableLoginView(false) - val existingUser = loginUsersSpinner.selectedItem != null && loginUsersSpinner.selectedItem is User && loginUsersSpinner.selectedItemPosition > 0 - val user = if (existingUser) { - loginUsersSpinner.selectedItem as User - } else { - User( - id = Random.nextLong(), - baseUrl = baseUrl, - username = loginUsernameText.text.toString(), - password = loginPasswordText.text.toString() - ) - } + val user = User( + baseUrl = baseUrl, + username = loginUsernameText.text.toString(), + password = loginPasswordText.text.toString() + ) lifecycleScope.launch(Dispatchers.IO) { Log.d(TAG, "Checking read access for user ${user.username} to topic ${topicUrl(baseUrl, topic)}") try { - val authorized = api.checkUserTopicRead(baseUrl, topic, user.username, user.password) + val authorized = api.authTopicRead(baseUrl, topic, user) if (authorized) { - Log.d(TAG, "Access granted for user ${user.username} to topic ${topicUrl(baseUrl, topic)}") - if (!existingUser) { - Log.d(TAG, "Adding new user ${user.username} to database") - repository.addUser(user) - } - dismiss(authUserId = user.id) + Log.d(TAG, "Access granted for user ${user.username} to topic ${topicUrl(baseUrl, topic)}, adding to database") + repository.addUser(user) + dismissDialog() } else { Log.w(TAG, "Access not allowed for user ${user.username} to topic ${topicUrl(baseUrl, topic)}") - val activity = activity ?: return@launch // We may have pressed "Cancel" - activity.runOnUiThread { - loginProgress.visibility = View.GONE - loginErrorImage.visibility = View.VISIBLE - enableLoginView(true) - Toast - .makeText(context, getString(R.string.add_dialog_login_error_not_authorized), Toast.LENGTH_LONG) - .show() - } + showToastAndReenableLoginView(getString(R.string.add_dialog_login_error_not_authorized)) } } catch (e: Exception) { - val activity = activity ?: return@launch // We may have pressed "Cancel" - activity.runOnUiThread { - loginProgress.visibility = View.GONE - loginErrorImage.visibility = View.VISIBLE - enableLoginView(true) - Toast - .makeText(context, getString(R.string.add_dialog_error_connection_failed, e.message), Toast.LENGTH_LONG) - .show() - } + Log.w(TAG, "Connection to topic failed during login: ${e.message}", e) + showToastAndReenableLoginView(e.message) } } } + private fun showToastAndReenableLoginView(message: String?) { + val activity = activity ?: return // We may have pressed "Cancel" + activity.runOnUiThread { + loginProgress.visibility = View.GONE + loginErrorImage.visibility = View.VISIBLE + enableLoginView(true) + Toast + .makeText(context, message, Toast.LENGTH_LONG) + .show() + } + } + private fun negativeButtonClick() { if (subscribeView.visibility == View.VISIBLE) { dialog?.cancel() @@ -420,7 +389,7 @@ class AddFragment : DialogFragment() { } } - private fun dismiss(authUserId: Long?) { + private fun dismissDialog() { Log.d(TAG, "Closing dialog and calling onSubscribe handler") val activity = activity?: return // We may have pressed "Cancel" activity.runOnUiThread { @@ -431,7 +400,7 @@ class AddFragment : DialogFragment() { } else { subscribeInstantDeliveryCheckbox.isChecked } - subscribeListener.onSubscribe(topic, baseUrl, instant, authUserId = authUserId) + subscribeListener.onSubscribe(topic, baseUrl, instant) dialog?.dismiss() } } @@ -463,21 +432,9 @@ class AddFragment : DialogFragment() { negativeButton.text = getString(R.string.add_dialog_button_back) subscribeView.visibility = View.GONE loginView.visibility = View.VISIBLE - - // Show/hide dropdown - val relevantUsers = users.filter { it.baseUrl == baseUrl } - if (relevantUsers.isEmpty()) { - loginUsersSpinner.visibility = View.GONE - loginUsersSpinner.adapter = ArrayAdapter(activity, R.layout.fragment_add_dialog_dropdown_item, emptyArray()) - if (loginUsernameText.requestFocus()) { - val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(loginUsernameText, InputMethodManager.SHOW_IMPLICIT) - } - } else { - val spinnerEntries = relevantUsers.toMutableList() - spinnerEntries.add(0, User(0, "", getString(R.string.add_dialog_login_new_user), "")) - loginUsersSpinner.adapter = ArrayAdapter(activity, R.layout.fragment_add_dialog_dropdown_item, spinnerEntries) - loginUsersSpinner.setSelection(1) + if (loginUsernameText.requestFocus()) { + val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(loginUsernameText, InputMethodManager.SHOW_IMPLICIT) } } @@ -498,7 +455,6 @@ class AddFragment : DialogFragment() { private fun enableLoginView(enable: Boolean) { loginUsernameText.isEnabled = enable loginPasswordText.isEnabled = enable - loginUsersSpinner.isEnabled = enable positiveButton.isEnabled = enable if (enable && loginUsernameText.requestFocus()) { val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager @@ -509,7 +465,6 @@ class AddFragment : DialogFragment() { private fun resetLoginView() { loginProgress.visibility = View.GONE loginErrorImage.visibility = View.GONE - loginUsersSpinner.visibility = View.VISIBLE loginUsernameText.visibility = View.VISIBLE loginUsernameText.text?.clear() loginPasswordText.visibility = View.VISIBLE 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 17524ec..28e396d 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -252,10 +252,10 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra onClearClick() true } - R.id.detail_menu_settings -> { + /*R.id.detail_menu_settings -> { onSettingsClick() true - } + }*/ R.id.detail_menu_unsubscribe -> { onDeleteClick() true 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 e12d8e0..3fe8b00 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt @@ -3,14 +3,10 @@ package io.heckel.ntfy.ui import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope -import androidx.preference.ListPreference -import androidx.preference.Preference -import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceFragmentCompat import io.heckel.ntfy.R import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Subscription -import io.heckel.ntfy.db.User import io.heckel.ntfy.log.Log import io.heckel.ntfy.service.SubscriberServiceManager import kotlinx.coroutines.Dispatchers @@ -72,53 +68,14 @@ class DetailSettingsActivity : AppCompatActivity() { val subscriptionId = arguments?.getLong(DetailActivity.EXTRA_SUBSCRIPTION_ID) ?: return lifecycleScope.launch(Dispatchers.IO) { val subscription = repository.getSubscription(subscriptionId) ?: return@launch - val users = repository.getUsers().filter { it.baseUrl == subscription.baseUrl } - val authUser = users.firstOrNull { it.id == subscription.authUserId } - Log.d(TAG, "subscription: $subscription") activity?.runOnUiThread { - loadView(subscription, authUser, users) + loadView(subscription) } } } - private fun loadView(subscription: Subscription, authUser: User?, users: List) { - // Login user - val anonUser = User(0, "", getString(R.string.detail_settings_auth_user_entry_anon), "") - val usersWithAnon = users.toMutableList() - usersWithAnon.add(0, anonUser) - val authUserPrefId = getString(R.string.detail_settings_auth_user_key) - val authUserPref: ListPreference? = findPreference(authUserPrefId) - authUserPref?.entries = usersWithAnon.map { it.username }.toTypedArray() - authUserPref?.entryValues = usersWithAnon.map { it.id.toString() }.toTypedArray() - authUserPref?.value = authUser?.id?.toString() ?: anonUser.id.toString() - Log.d(TAG, "--> ${authUser?.id?.toString() ?: anonUser.id.toString()}") - authUserPref?.summaryProvider = Preference.SummaryProvider { pref -> - when (pref.value) { - anonUser.id.toString() -> getString(R.string.detail_settings_auth_user_summary_none) - else -> { - val username = users.firstOrNull { it.id.toString() == pref.value } ?: "?" - getString(R.string.detail_settings_auth_user_summary_user_x, username) - } - } - } - authUserPref?.preferenceDataStore = object : PreferenceDataStore() { - override fun putString(key: String?, value: String?) { - val newAuthUserId = when (value) { - anonUser.id.toString() -> null - else -> value?.toLongOrNull() - } - lifecycleScope.launch(Dispatchers.IO) { - Log.d(TAG, "Updating subscription ${subscription.id} with new auth user ID $newAuthUserId") - repository.updateSubscriptionAuthUserId(subscription.id, newAuthUserId) - Log.d(TAG, "after save: ${repository.getSubscription(subscription.id)}") - serviceManager.refresh() - } - } - override fun getString(key: String?, defValue: String?): String? { - Log.d(TAG, "getstring called $key $defValue -> ${authUser?.id?.toString() ?: anonUser.id.toString()}") - return authUser?.id?.toString() ?: anonUser.id.toString() - } - } + private fun loadView(subscription: Subscription) { + // ... } } 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 38081a9..e9b0e83 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -348,7 +348,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc newFragment.show(supportFragmentManager, AddFragment.TAG) } - override fun onSubscribe(topic: String, baseUrl: String, instant: Boolean, authUserId: Long?) { + override fun onSubscribe(topic: String, baseUrl: String, instant: Boolean) { Log.d(TAG, "Adding subscription ${topicShortUrl(baseUrl, topic)}") // Add subscription to database @@ -358,7 +358,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc topic = topic, instant = instant, mutedUntil = 0, - authUserId = authUserId, upAppId = null, upConnectorToken = null, totalCount = 0, 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 6248ce4..1878d0a 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -4,7 +4,6 @@ import android.Manifest import android.content.ClipData import android.content.ClipboardManager import android.content.Context -import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -25,7 +24,6 @@ import io.heckel.ntfy.R import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.User import io.heckel.ntfy.log.Log -import io.heckel.ntfy.service.SubscriberService import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.util.formatBytes import io.heckel.ntfy.util.formatDateShort @@ -439,7 +437,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere } private fun restartService() { - serviceManager.stop() // Service will auto-restart + serviceManager.restart() // Service will auto-restart } private fun copyLogsToClipboard() { @@ -543,12 +541,12 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere fun reload() { preferenceScreen.removeAll() lifecycleScope.launch(Dispatchers.IO) { - val userIdsWithTopics = repository.getSubscriptions() - .groupBy { it.authUserId } + val baseUrlsWithTopics = repository.getSubscriptions() + .groupBy { it.baseUrl } .mapValues { e -> e.value.map { it.topic } } val usersByBaseUrl = repository.getUsers() .map { user -> - val topics = userIdsWithTopics[user.id] ?: emptyList() + val topics = baseUrlsWithTopics[user.baseUrl] ?: emptyList() UserWithMetadata(user, topics) } .groupBy { it.user.baseUrl } @@ -559,6 +557,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere } private fun addUserPreferences(usersByBaseUrl: Map>) { + val baseUrlsInUse = ArrayList(usersByBaseUrl.keys) usersByBaseUrl.forEach { entry -> val baseUrl = entry.key val users = entry.value @@ -580,7 +579,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere preference.onPreferenceClickListener = OnPreferenceClickListener { _ -> activity?.let { UserFragment - .newInstance(user.user) + .newInstance(user.user, baseUrlsInUse) .show(it.supportFragmentManager, UserFragment.TAG) } true @@ -600,7 +599,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere userAddPref.onPreferenceClickListener = OnPreferenceClickListener { _ -> activity?.let { UserFragment - .newInstance(user = null) + .newInstance(user = null, baseUrlsInUse = baseUrlsInUse) .show(it.supportFragmentManager, UserFragment.TAG) } true @@ -630,18 +629,17 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere override fun onUpdateUser(dialog: DialogFragment, user: User) { lifecycleScope.launch(Dispatchers.IO) { repository.updateUser(user) - serviceManager.stop() // Editing does not change the user ID + serviceManager.restart() // Editing does not change the user ID runOnUiThread { userSettingsFragment.reload() } } } - override fun onDeleteUser(dialog: DialogFragment, authUserId: Long) { + override fun onDeleteUser(dialog: DialogFragment, baseUrl: String) { lifecycleScope.launch(Dispatchers.IO) { - repository.removeAuthUserFromSubscriptions(authUserId) - repository.deleteUser(authUserId) - serviceManager.refresh() // authUserId changed, so refresh is enough + repository.deleteUser(baseUrl) + serviceManager.restart() runOnUiThread { userSettingsFragment.reload() } diff --git a/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt index ced759e..d5e16c0 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt @@ -19,6 +19,7 @@ import kotlin.random.Random class UserFragment : DialogFragment() { private var user: User? = null + private lateinit var baseUrlsInUse: ArrayList private lateinit var listener: UserDialogListener private lateinit var baseUrlView: TextInputEditText @@ -29,7 +30,7 @@ class UserFragment : DialogFragment() { interface UserDialogListener { fun onAddUser(dialog: DialogFragment, user: User) fun onUpdateUser(dialog: DialogFragment, user: User) - fun onDeleteUser(dialog: DialogFragment, authUserId: Long) + fun onDeleteUser(dialog: DialogFragment, baseUrl: String) } override fun onAttach(context: Context) { @@ -39,15 +40,17 @@ class UserFragment : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { // Reconstruct user (if it is present in the bundle) - val userId = arguments?.getLong(BUNDLE_USER_ID) val baseUrl = arguments?.getString(BUNDLE_BASE_URL) val username = arguments?.getString(BUNDLE_USERNAME) val password = arguments?.getString(BUNDLE_PASSWORD) - if (userId != null && baseUrl != null && username != null && password != null) { - user = User(userId, baseUrl, username, password) + if (baseUrl != null && username != null && password != null) { + user = User(baseUrl, username, password) } + // Required for validation + baseUrlsInUse = arguments?.getStringArrayList(BUNDLE_BASE_URLS_IN_USE) ?: arrayListOf() + // Build root view val view = requireActivity().layoutInflater.inflate(R.layout.fragment_user_dialog, null) @@ -83,8 +86,8 @@ class UserFragment : DialogFragment() { } if (user != null) { builder.setNeutralButton(R.string.user_dialog_button_delete) { _, _ -> - if (this::listener.isInitialized && userId != null) { - listener.onDeleteUser(this, userId) + if (this::listener.isInitialized) { + listener.onDeleteUser(this, user!!.baseUrl) } } } @@ -131,7 +134,7 @@ class UserFragment : DialogFragment() { val username = usernameView.text?.toString() ?: "" val password = passwordView.text?.toString() ?: "" if (user == null) { - user = User(Random.nextLong(), baseUrl, username, password) + user = User(baseUrl, username, password) listener.onAddUser(this, user!!) } else { user = if (password.isNotEmpty()) { @@ -149,6 +152,7 @@ class UserFragment : DialogFragment() { val password = passwordView.text?.toString() ?: "" if (user == null) { positiveButton.isEnabled = (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) + && !baseUrlsInUse.contains(baseUrl) && username.isNotEmpty() && password.isNotEmpty() } else { positiveButton.isEnabled = username.isNotEmpty() // Unchanged if left blank @@ -157,21 +161,21 @@ class UserFragment : DialogFragment() { companion object { const val TAG = "NtfyUserFragment" - private const val BUNDLE_USER_ID = "userId" private const val BUNDLE_BASE_URL = "baseUrl" private const val BUNDLE_USERNAME = "username" private const val BUNDLE_PASSWORD = "password" + private const val BUNDLE_BASE_URLS_IN_USE = "baseUrlsInUse" - fun newInstance(user: User?): UserFragment { + fun newInstance(user: User?, baseUrlsInUse: ArrayList): UserFragment { val fragment = UserFragment() val args = Bundle() + args.putStringArrayList(BUNDLE_BASE_URLS_IN_USE, baseUrlsInUse) if (user != null) { - args.putLong(BUNDLE_USER_ID, user.id) args.putString(BUNDLE_BASE_URL, user.baseUrl) args.putString(BUNDLE_USERNAME, user.username) args.putString(BUNDLE_PASSWORD, user.password) - fragment.arguments = args } + fragment.arguments = args return fragment } } diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt index 06e87df..9df1c54 100644 --- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt +++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt @@ -67,7 +67,6 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { topic = topic, instant = true, // No Firebase, always instant! mutedUntil = 0, - authUserId = null, // FIXME add UP user in settings upAppId = appId, upConnectorToken = connectorToken, totalCount = 0, diff --git a/app/src/main/res/layout/fragment_add_dialog.xml b/app/src/main/res/layout/fragment_add_dialog.xml index 404a600..7d16f21 100644 --- a/app/src/main/res/layout/fragment_add_dialog.xml +++ b/app/src/main/res/layout/fragment_add_dialog.xml @@ -1,7 +1,7 @@ - + app:layout_constraintTop_toBottomOf="@id/add_dialog_login_title" android:paddingEnd="4dp"/> + android:layout_marginTop="10dp" app:layout_constraintTop_toBottomOf="@+id/add_dialog_login_description"/> - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0513d5e..e10285c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -100,6 +100,10 @@ Back Login Connection failed: %1$s + Login required + This topic requires you to login. Please type in a username and password. + Username + Password Login failed. User not authorized. New user @@ -259,7 +263,7 @@ Used by topics %1$s Add users Add new user - Create a new user that can be associated to topics. You can also create a new user when adding a topic. + Create a new user for a new server UnifiedPush Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org. UnifiedPushEnabled