Remove per-topic user association; this is so. much. better.

This commit is contained in:
Philipp Heckel 2022-01-31 19:34:34 -05:00
parent 6593d60d43
commit 28bfd087c7
17 changed files with 139 additions and 269 deletions

View file

@ -2,11 +2,11 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 7, "version": 7,
"identityHash": "f8551fac095d795a83ba7a95277e3f0f", "identityHash": "eda2cb9740c4542f24462779eb6ff81d",
"entities": [ "entities": [
{ {
"tableName": "Subscription", "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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -38,12 +38,6 @@
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
}, },
{
"fieldPath": "authUserId",
"columnName": "authUserId",
"affinity": "INTEGER",
"notNull": false
},
{ {
"fieldPath": "upAppId", "fieldPath": "upAppId",
"columnName": "upAppId", "columnName": "upAppId",
@ -206,14 +200,8 @@
}, },
{ {
"tableName": "User", "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": [ "fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{ {
"fieldPath": "baseUrl", "fieldPath": "baseUrl",
"columnName": "baseUrl", "columnName": "baseUrl",
@ -235,9 +223,9 @@
], ],
"primaryKey": { "primaryKey": {
"columnNames": [ "columnNames": [
"id" "baseUrl"
], ],
"autoGenerate": true "autoGenerate": false
}, },
"indices": [], "indices": [],
"foreignKeys": [] "foreignKeys": []
@ -296,7 +284,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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')"
] ]
} }
} }

View file

@ -14,7 +14,6 @@ data class Subscription(
@ColumnInfo(name = "topic") val topic: String, @ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "instant") val instant: Boolean, @ColumnInfo(name = "instant") val instant: Boolean,
@ColumnInfo(name = "mutedUntil") val mutedUntil: Long, // TODO notificationSound, notificationSchedule @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 = "upAppId") val upAppId: String?, // UnifiedPush application package name
@ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token @ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token
// TODO autoDownloadAttachments, minPriority // TODO autoDownloadAttachments, minPriority
@ -23,8 +22,8 @@ data class Subscription(
@Ignore val lastActive: Long = 0, // Unix timestamp @Ignore val lastActive: Long = 0, // Unix timestamp
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE @Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
) { ) {
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, authUserId: Long?, upAppId: String, upConnectorToken: String) : constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, upAppId: String, upConnectorToken: String) :
this(id, baseUrl, topic, instant, mutedUntil, authUserId, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE) this(id, baseUrl, topic, instant, mutedUntil, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
} }
enum class ConnectionState { enum class ConnectionState {
@ -37,7 +36,6 @@ data class SubscriptionWithMetadata(
val topic: String, val topic: String,
val instant: Boolean, val instant: Boolean,
val mutedUntil: Long, val mutedUntil: Long,
val authUserId: Long?,
val upAppId: String?, val upAppId: String?,
val upConnectorToken: String?, val upConnectorToken: String?,
val totalCount: Int, val totalCount: Int,
@ -82,8 +80,7 @@ const val PROGRESS_DONE = 100
@Entity @Entity
data class User( data class User(
@PrimaryKey(autoGenerate = true) val id: Long, @PrimaryKey @ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "username") val username: String, @ColumnInfo(name = "username") val username: String,
@ColumnInfo(name = "password") val password: String @ColumnInfo(name = "password") val password: String
) { ) {
@ -194,7 +191,7 @@ abstract class Database : RoomDatabase() {
interface SubscriptionDao { interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive
@ -207,7 +204,7 @@ interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive
@ -220,7 +217,7 @@ interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive
@ -233,7 +230,7 @@ interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive
@ -246,7 +243,7 @@ interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive
@ -263,14 +260,8 @@ interface SubscriptionDao {
@Update @Update
fun update(subscription: Subscription) 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") @Query("DELETE FROM subscription WHERE id = :subscriptionId")
fun remove(subscriptionId: Long) fun remove(subscriptionId: Long)
@Query("UPDATE subscription SET authUserId = null WHERE authUserId = :authUserId")
fun removeAuthUserFromSubscriptions(authUserId: Long)
} }
@Dao @Dao
@ -311,14 +302,14 @@ interface UserDao {
@Query("SELECT * FROM user ORDER BY username") @Query("SELECT * FROM user ORDER BY username")
suspend fun list(): List<User> suspend fun list(): List<User>
@Query("SELECT * FROM user WHERE id = :id") @Query("SELECT * FROM user WHERE baseUrl = :baseUrl")
suspend fun get(id: Long): User suspend fun get(baseUrl: String): User?
@Update @Update
suspend fun update(user: User) suspend fun update(user: User)
@Query("DELETE FROM user WHERE id = :id") @Query("DELETE FROM user WHERE baseUrl = :baseUrl")
suspend fun delete(id: Long) suspend fun delete(baseUrl: String)
} }
@Dao @Dao

View file

@ -78,20 +78,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
subscriptionDao.update(subscription) subscriptionDao.update(subscription)
} }
fun updateSubscriptionAuthUserId(subscriptionId: Long, authUserId: Long?) {
subscriptionDao.updateSubscriptionAuthUserId(subscriptionId, authUserId)
}
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun removeSubscription(subscriptionId: Long) { suspend fun removeSubscription(subscriptionId: Long) {
subscriptionDao.remove(subscriptionId) subscriptionDao.remove(subscriptionId)
} }
fun removeAuthUserFromSubscriptions(authUserId: Long) {
subscriptionDao.removeAuthUserFromSubscriptions(authUserId)
}
fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> { fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> {
return notificationDao.listFlow(subscriptionId).asLiveData() return notificationDao.listFlow(subscriptionId).asLiveData()
} }
@ -152,12 +144,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
userDao.update(user) userDao.update(user)
} }
suspend fun getUser(userId: Long): User { suspend fun getUser(baseUrl: String): User? {
return userDao.get(userId) return userDao.get(baseUrl)
} }
suspend fun deleteUser(userId: Long) { suspend fun deleteUser(baseUrl: String) {
userDao.delete(userId) userDao.delete(baseUrl)
} }
fun getPollWorkerVersion(): Int { fun getPollWorkerVersion(): Int {
@ -346,7 +338,6 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
topic = s.topic, topic = s.topic,
instant = s.instant, instant = s.instant,
mutedUntil = s.mutedUntil, mutedUntil = s.mutedUntil,
authUserId = s.authUserId,
upAppId = s.upAppId, upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken, upConnectorToken = s.upConnectorToken,
totalCount = s.totalCount, totalCount = s.totalCount,
@ -367,7 +358,6 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
topic = s.topic, topic = s.topic,
instant = s.instant, instant = s.instant,
mutedUntil = s.mutedUntil, mutedUntil = s.mutedUntil,
authUserId = s.authUserId,
upAppId = s.upAppId, upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken, upConnectorToken = s.upConnectorToken,
totalCount = s.totalCount, totalCount = s.totalCount,

View file

@ -12,7 +12,6 @@ import io.heckel.ntfy.util.topicUrlJsonPoll
import okhttp3.* import okhttp3.*
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException import java.io.IOException
import java.nio.charset.StandardCharsets
import java.nio.charset.StandardCharsets.UTF_8 import java.nio.charset.StandardCharsets.UTF_8
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.random.Random import kotlin.random.Random
@ -134,30 +133,26 @@ class ApiService {
return call return call
} }
fun checkAnonTopicRead(baseUrl: String, topic: String): Boolean { fun authTopicRead(baseUrl: String, topic: String, user: User?): Boolean {
return checkTopicRead(baseUrl, topic, creds = null) 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)}")
} }
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 {
val url = topicUrlAuth(baseUrl, topic) val url = topicUrlAuth(baseUrl, topic)
val builder = Request.Builder() val builder = Request.Builder()
.get() .get()
.url(url) .url(url)
.addHeader("User-Agent", USER_AGENT) .addHeader("User-Agent", USER_AGENT)
if (creds != null) { if (user != null) {
builder.addHeader("Authorization", creds) builder.addHeader("Authorization", Credentials.basic(user.username, user.password, UTF_8))
} }
val request = builder.build() val request = builder.build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (creds == null) { return if (user == null) {
return response.isSuccessful || response.code == 404 // Treat 404 as success (old server; to be removed in future versions) response.isSuccessful || response.code == 404 // Treat 404 as success (old server; to be removed in future versions)
} else { } else {
return response.isSuccessful response.isSuccessful
} }
} }
} }

View file

@ -8,6 +8,5 @@ interface Connection {
data class ConnectionId( data class ConnectionId(
val baseUrl: String, val baseUrl: String,
val authUserId: Long?,
val topicsToSubscriptionIds: Map<String, Long> val topicsToSubscriptionIds: Map<String, Long>
) )

View file

@ -155,7 +155,7 @@ class SubscriberService : Service() {
.filter { s -> s.instant } .filter { s -> s.instant }
val activeConnectionIds = connections.keys().toList().toSet() val activeConnectionIds = connections.keys().toList().toSet()
val desiredConnectionIds = instantSubscriptions // Set<ConnectionId> val desiredConnectionIds = instantSubscriptions // Set<ConnectionId>
.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 }) } .map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }) }
.toSet() .toSet()
val newConnectionIds = desiredConnectionIds subtract activeConnectionIds val newConnectionIds = desiredConnectionIds subtract activeConnectionIds
@ -183,11 +183,7 @@ class SubscriberService : Service() {
val since = System.currentTimeMillis()/1000 val since = System.currentTimeMillis()/1000
val serviceActive = { -> isServiceStarted } val serviceActive = { -> isServiceStarted }
val user = if (connectionId.authUserId != null) { val user = repository.getUser(connectionId.baseUrl)
repository.getUser(connectionId.authUserId)
} else {
null
}
val connection = if (repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS) { val connection = if (repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS) {
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
WsConnection(connectionId, repository, user, since, ::onStateChanged, ::onNotificationReceived, alarmManager) WsConnection(connectionId, repository, user, since, ::onStateChanged, ::onNotificationReceived, alarmManager)

View file

@ -24,7 +24,7 @@ class SubscriberServiceManager(private val context: Context) {
workManager.enqueue(startServiceRequest) workManager.enqueue(startServiceRequest)
} }
fun stop() { fun restart() {
Intent(context, SubscriberService::class.java).also { intent -> Intent(context, SubscriberService::class.java).also { intent ->
context.stopService(intent) // Service will auto-restart context.stopService(intent) // Service will auto-restart
} }

View file

@ -24,7 +24,6 @@ import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.random.Random
class AddFragment : DialogFragment() { class AddFragment : DialogFragment() {
private val api = ApiService() private val api = ApiService()
@ -51,7 +50,6 @@ class AddFragment : DialogFragment() {
// Login page // Login page
private lateinit var users: List<User> private lateinit var users: List<User>
private lateinit var loginUsersSpinner: Spinner
private lateinit var loginUsernameText: TextInputEditText private lateinit var loginUsernameText: TextInputEditText
private lateinit var loginPasswordText: TextInputEditText private lateinit var loginPasswordText: TextInputEditText
private lateinit var loginProgress: ProgressBar private lateinit var loginProgress: ProgressBar
@ -60,7 +58,7 @@ class AddFragment : DialogFragment() {
private lateinit var baseUrls: List<String> // List of base URLs already used, excluding app_base_url private lateinit var baseUrls: List<String> // List of base URLs already used, excluding app_base_url
interface SubscribeListener { 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) { override fun onAttach(context: Context) {
@ -98,7 +96,6 @@ class AddFragment : DialogFragment() {
subscribeErrorImage = view.findViewById(R.id.add_dialog_error_image) subscribeErrorImage = view.findViewById(R.id.add_dialog_error_image)
// Fields for "login page" // Fields for "login page"
loginUsersSpinner = view.findViewById(R.id.add_dialog_login_users_spinner)
loginUsernameText = view.findViewById(R.id.add_dialog_login_username) loginUsernameText = view.findViewById(R.id.add_dialog_login_username)
loginPasswordText = view.findViewById(R.id.add_dialog_login_password) loginPasswordText = view.findViewById(R.id.add_dialog_login_password)
loginProgress = view.findViewById(R.id.add_dialog_login_progress) loginProgress = view.findViewById(R.id.add_dialog_login_progress)
@ -178,31 +175,6 @@ class AddFragment : DialogFragment() {
// Show/hide based on flavor // Show/hide based on flavor
subscribeInstantDeliveryBox.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE 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 // Username/password validation on type
val textWatcher = object : TextWatcher { val textWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
@ -288,96 +260,93 @@ class AddFragment : DialogFragment() {
val topic = subscribeTopicText.text.toString() val topic = subscribeTopicText.text.toString()
val baseUrl = getBaseUrl() val baseUrl = getBaseUrl()
if (subscribeView.visibility == View.VISIBLE) { if (subscribeView.visibility == View.VISIBLE) {
checkAnonReadAndMaybeShowLogin(baseUrl, topic) checkReadAndMaybeShowLogin(baseUrl, topic)
} else if (loginView.visibility == View.VISIBLE) { } else if (loginView.visibility == View.VISIBLE) {
loginAndMaybeDismiss(baseUrl, topic) loginAndMaybeDismiss(baseUrl, topic)
} }
} }
private fun checkAnonReadAndMaybeShowLogin(baseUrl: String, topic: String) { private fun checkReadAndMaybeShowLogin(baseUrl: String, topic: String) {
subscribeProgress.visibility = View.VISIBLE subscribeProgress.visibility = View.VISIBLE
subscribeErrorImage.visibility = View.GONE subscribeErrorImage.visibility = View.GONE
enableSubscribeView(false) enableSubscribeView(false)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
Log.d(TAG, "Checking anonymous read access to topic ${topicUrl(baseUrl, topic)}")
try { try {
val authorized = api.checkAnonTopicRead(baseUrl, topic) val user = repository.getUser(baseUrl) // May be null
val authorized = api.authTopicRead(baseUrl, topic, user)
if (authorized) { if (authorized) {
Log.d(TAG, "Anonymous access granted to topic ${topicUrl(baseUrl, topic)}") Log.d(TAG, "Access granted to topic ${topicUrl(baseUrl, topic)}")
dismiss(authUserId = null) dismissDialog()
} else { } else {
Log.w(TAG, "Anonymous access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog") 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" val activity = activity ?: return@launch // We may have pressed "Cancel"
activity.runOnUiThread { activity.runOnUiThread {
showLoginView(activity, baseUrl) showLoginView(activity, baseUrl)
} }
} }
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Connection to topic failed: ${e.message}", e) Log.w(TAG, "Connection to topic failed: ${e.message}", e)
val activity = activity ?: return@launch // We may have pressed "Cancel" showToastAndReenableSubscribeView(e.message)
}
}
}
private fun showToastAndReenableSubscribeView(message: String?) {
val activity = activity ?: return // We may have pressed "Cancel"
activity.runOnUiThread { activity.runOnUiThread {
subscribeProgress.visibility = View.GONE subscribeProgress.visibility = View.GONE
subscribeErrorImage.visibility = View.VISIBLE subscribeErrorImage.visibility = View.VISIBLE
enableSubscribeView(true) enableSubscribeView(true)
Toast Toast
.makeText(context, getString(R.string.add_dialog_error_connection_failed, e.message), Toast.LENGTH_LONG) .makeText(context, message, Toast.LENGTH_LONG)
.show() .show()
} }
} }
}
}
private fun loginAndMaybeDismiss(baseUrl: String, topic: String) { private fun loginAndMaybeDismiss(baseUrl: String, topic: String) {
loginProgress.visibility = View.VISIBLE loginProgress.visibility = View.VISIBLE
loginErrorImage.visibility = View.GONE loginErrorImage.visibility = View.GONE
enableLoginView(false) enableLoginView(false)
val existingUser = loginUsersSpinner.selectedItem != null && loginUsersSpinner.selectedItem is User && loginUsersSpinner.selectedItemPosition > 0 val user = User(
val user = if (existingUser) {
loginUsersSpinner.selectedItem as User
} else {
User(
id = Random.nextLong(),
baseUrl = baseUrl, baseUrl = baseUrl,
username = loginUsernameText.text.toString(), username = loginUsernameText.text.toString(),
password = loginPasswordText.text.toString() password = loginPasswordText.text.toString()
) )
}
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
Log.d(TAG, "Checking read access for user ${user.username} to topic ${topicUrl(baseUrl, topic)}") Log.d(TAG, "Checking read access for user ${user.username} to topic ${topicUrl(baseUrl, topic)}")
try { try {
val authorized = api.checkUserTopicRead(baseUrl, topic, user.username, user.password) val authorized = api.authTopicRead(baseUrl, topic, user)
if (authorized) { if (authorized) {
Log.d(TAG, "Access granted for user ${user.username} to topic ${topicUrl(baseUrl, topic)}") Log.d(TAG, "Access granted for user ${user.username} to topic ${topicUrl(baseUrl, topic)}, adding to database")
if (!existingUser) {
Log.d(TAG, "Adding new user ${user.username} to database")
repository.addUser(user) repository.addUser(user)
} dismissDialog()
dismiss(authUserId = user.id)
} else { } else {
Log.w(TAG, "Access not allowed for user ${user.username} to topic ${topicUrl(baseUrl, topic)}") 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" showToastAndReenableLoginView(getString(R.string.add_dialog_login_error_not_authorized))
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()
}
} }
} catch (e: Exception) { } catch (e: Exception) {
val activity = activity ?: return@launch // We may have pressed "Cancel" 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 { activity.runOnUiThread {
loginProgress.visibility = View.GONE loginProgress.visibility = View.GONE
loginErrorImage.visibility = View.VISIBLE loginErrorImage.visibility = View.VISIBLE
enableLoginView(true) enableLoginView(true)
Toast Toast
.makeText(context, getString(R.string.add_dialog_error_connection_failed, e.message), Toast.LENGTH_LONG) .makeText(context, message, Toast.LENGTH_LONG)
.show() .show()
} }
} }
}
}
private fun negativeButtonClick() { private fun negativeButtonClick() {
if (subscribeView.visibility == View.VISIBLE) { if (subscribeView.visibility == View.VISIBLE) {
@ -420,7 +389,7 @@ class AddFragment : DialogFragment() {
} }
} }
private fun dismiss(authUserId: Long?) { private fun dismissDialog() {
Log.d(TAG, "Closing dialog and calling onSubscribe handler") Log.d(TAG, "Closing dialog and calling onSubscribe handler")
val activity = activity?: return // We may have pressed "Cancel" val activity = activity?: return // We may have pressed "Cancel"
activity.runOnUiThread { activity.runOnUiThread {
@ -431,7 +400,7 @@ class AddFragment : DialogFragment() {
} else { } else {
subscribeInstantDeliveryCheckbox.isChecked subscribeInstantDeliveryCheckbox.isChecked
} }
subscribeListener.onSubscribe(topic, baseUrl, instant, authUserId = authUserId) subscribeListener.onSubscribe(topic, baseUrl, instant)
dialog?.dismiss() dialog?.dismiss()
} }
} }
@ -463,22 +432,10 @@ class AddFragment : DialogFragment() {
negativeButton.text = getString(R.string.add_dialog_button_back) negativeButton.text = getString(R.string.add_dialog_button_back)
subscribeView.visibility = View.GONE subscribeView.visibility = View.GONE
loginView.visibility = View.VISIBLE 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<User>())
if (loginUsernameText.requestFocus()) { if (loginUsernameText.requestFocus()) {
val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(loginUsernameText, InputMethodManager.SHOW_IMPLICIT) 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)
}
} }
private fun enableSubscribeView(enable: Boolean) { private fun enableSubscribeView(enable: Boolean) {
@ -498,7 +455,6 @@ class AddFragment : DialogFragment() {
private fun enableLoginView(enable: Boolean) { private fun enableLoginView(enable: Boolean) {
loginUsernameText.isEnabled = enable loginUsernameText.isEnabled = enable
loginPasswordText.isEnabled = enable loginPasswordText.isEnabled = enable
loginUsersSpinner.isEnabled = enable
positiveButton.isEnabled = enable positiveButton.isEnabled = enable
if (enable && loginUsernameText.requestFocus()) { if (enable && loginUsernameText.requestFocus()) {
val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
@ -509,7 +465,6 @@ class AddFragment : DialogFragment() {
private fun resetLoginView() { private fun resetLoginView() {
loginProgress.visibility = View.GONE loginProgress.visibility = View.GONE
loginErrorImage.visibility = View.GONE loginErrorImage.visibility = View.GONE
loginUsersSpinner.visibility = View.VISIBLE
loginUsernameText.visibility = View.VISIBLE loginUsernameText.visibility = View.VISIBLE
loginUsernameText.text?.clear() loginUsernameText.text?.clear()
loginPasswordText.visibility = View.VISIBLE loginPasswordText.visibility = View.VISIBLE

View file

@ -252,10 +252,10 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
onClearClick() onClearClick()
true true
} }
R.id.detail_menu_settings -> { /*R.id.detail_menu_settings -> {
onSettingsClick() onSettingsClick()
true true
} }*/
R.id.detail_menu_unsubscribe -> { R.id.detail_menu_unsubscribe -> {
onDeleteClick() onDeleteClick()
true true

View file

@ -3,14 +3,10 @@ package io.heckel.ntfy.ui
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceDataStore
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.db.User
import io.heckel.ntfy.log.Log import io.heckel.ntfy.log.Log
import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.service.SubscriberServiceManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -72,53 +68,14 @@ class DetailSettingsActivity : AppCompatActivity() {
val subscriptionId = arguments?.getLong(DetailActivity.EXTRA_SUBSCRIPTION_ID) ?: return val subscriptionId = arguments?.getLong(DetailActivity.EXTRA_SUBSCRIPTION_ID) ?: return
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val subscription = repository.getSubscription(subscriptionId) ?: return@launch 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 { activity?.runOnUiThread {
loadView(subscription, authUser, users) loadView(subscription)
} }
} }
} }
private fun loadView(subscription: Subscription, authUser: User?, users: List<User>) { private fun loadView(subscription: Subscription) {
// 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<ListPreference> { 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()
}
}
} }
} }

View file

@ -348,7 +348,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
newFragment.show(supportFragmentManager, AddFragment.TAG) 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)}") Log.d(TAG, "Adding subscription ${topicShortUrl(baseUrl, topic)}")
// Add subscription to database // Add subscription to database
@ -358,7 +358,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
topic = topic, topic = topic,
instant = instant, instant = instant,
mutedUntil = 0, mutedUntil = 0,
authUserId = authUserId,
upAppId = null, upAppId = null,
upConnectorToken = null, upConnectorToken = null,
totalCount = 0, totalCount = 0,

View file

@ -4,7 +4,6 @@ import android.Manifest
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@ -25,7 +24,6 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.User import io.heckel.ntfy.db.User
import io.heckel.ntfy.log.Log import io.heckel.ntfy.log.Log
import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.service.SubscriberServiceManager import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.formatBytes import io.heckel.ntfy.util.formatBytes
import io.heckel.ntfy.util.formatDateShort import io.heckel.ntfy.util.formatDateShort
@ -439,7 +437,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
} }
private fun restartService() { private fun restartService() {
serviceManager.stop() // Service will auto-restart serviceManager.restart() // Service will auto-restart
} }
private fun copyLogsToClipboard() { private fun copyLogsToClipboard() {
@ -543,12 +541,12 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
fun reload() { fun reload() {
preferenceScreen.removeAll() preferenceScreen.removeAll()
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val userIdsWithTopics = repository.getSubscriptions() val baseUrlsWithTopics = repository.getSubscriptions()
.groupBy { it.authUserId } .groupBy { it.baseUrl }
.mapValues { e -> e.value.map { it.topic } } .mapValues { e -> e.value.map { it.topic } }
val usersByBaseUrl = repository.getUsers() val usersByBaseUrl = repository.getUsers()
.map { user -> .map { user ->
val topics = userIdsWithTopics[user.id] ?: emptyList() val topics = baseUrlsWithTopics[user.baseUrl] ?: emptyList()
UserWithMetadata(user, topics) UserWithMetadata(user, topics)
} }
.groupBy { it.user.baseUrl } .groupBy { it.user.baseUrl }
@ -559,6 +557,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
} }
private fun addUserPreferences(usersByBaseUrl: Map<String, List<UserWithMetadata>>) { private fun addUserPreferences(usersByBaseUrl: Map<String, List<UserWithMetadata>>) {
val baseUrlsInUse = ArrayList(usersByBaseUrl.keys)
usersByBaseUrl.forEach { entry -> usersByBaseUrl.forEach { entry ->
val baseUrl = entry.key val baseUrl = entry.key
val users = entry.value val users = entry.value
@ -580,7 +579,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
preference.onPreferenceClickListener = OnPreferenceClickListener { _ -> preference.onPreferenceClickListener = OnPreferenceClickListener { _ ->
activity?.let { activity?.let {
UserFragment UserFragment
.newInstance(user.user) .newInstance(user.user, baseUrlsInUse)
.show(it.supportFragmentManager, UserFragment.TAG) .show(it.supportFragmentManager, UserFragment.TAG)
} }
true true
@ -600,7 +599,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
userAddPref.onPreferenceClickListener = OnPreferenceClickListener { _ -> userAddPref.onPreferenceClickListener = OnPreferenceClickListener { _ ->
activity?.let { activity?.let {
UserFragment UserFragment
.newInstance(user = null) .newInstance(user = null, baseUrlsInUse = baseUrlsInUse)
.show(it.supportFragmentManager, UserFragment.TAG) .show(it.supportFragmentManager, UserFragment.TAG)
} }
true true
@ -630,18 +629,17 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
override fun onUpdateUser(dialog: DialogFragment, user: User) { override fun onUpdateUser(dialog: DialogFragment, user: User) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
repository.updateUser(user) repository.updateUser(user)
serviceManager.stop() // Editing does not change the user ID serviceManager.restart() // Editing does not change the user ID
runOnUiThread { runOnUiThread {
userSettingsFragment.reload() userSettingsFragment.reload()
} }
} }
} }
override fun onDeleteUser(dialog: DialogFragment, authUserId: Long) { override fun onDeleteUser(dialog: DialogFragment, baseUrl: String) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
repository.removeAuthUserFromSubscriptions(authUserId) repository.deleteUser(baseUrl)
repository.deleteUser(authUserId) serviceManager.restart()
serviceManager.refresh() // authUserId changed, so refresh is enough
runOnUiThread { runOnUiThread {
userSettingsFragment.reload() userSettingsFragment.reload()
} }

View file

@ -19,6 +19,7 @@ import kotlin.random.Random
class UserFragment : DialogFragment() { class UserFragment : DialogFragment() {
private var user: User? = null private var user: User? = null
private lateinit var baseUrlsInUse: ArrayList<String>
private lateinit var listener: UserDialogListener private lateinit var listener: UserDialogListener
private lateinit var baseUrlView: TextInputEditText private lateinit var baseUrlView: TextInputEditText
@ -29,7 +30,7 @@ class UserFragment : DialogFragment() {
interface UserDialogListener { interface UserDialogListener {
fun onAddUser(dialog: DialogFragment, user: User) fun onAddUser(dialog: DialogFragment, user: User)
fun onUpdateUser(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) { override fun onAttach(context: Context) {
@ -39,15 +40,17 @@ class UserFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
// Reconstruct user (if it is present in the bundle) // Reconstruct user (if it is present in the bundle)
val userId = arguments?.getLong(BUNDLE_USER_ID)
val baseUrl = arguments?.getString(BUNDLE_BASE_URL) val baseUrl = arguments?.getString(BUNDLE_BASE_URL)
val username = arguments?.getString(BUNDLE_USERNAME) val username = arguments?.getString(BUNDLE_USERNAME)
val password = arguments?.getString(BUNDLE_PASSWORD) val password = arguments?.getString(BUNDLE_PASSWORD)
if (userId != null && baseUrl != null && username != null && password != null) { if (baseUrl != null && username != null && password != null) {
user = User(userId, baseUrl, username, password) user = User(baseUrl, username, password)
} }
// Required for validation
baseUrlsInUse = arguments?.getStringArrayList(BUNDLE_BASE_URLS_IN_USE) ?: arrayListOf()
// Build root view // Build root view
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_user_dialog, null) val view = requireActivity().layoutInflater.inflate(R.layout.fragment_user_dialog, null)
@ -83,8 +86,8 @@ class UserFragment : DialogFragment() {
} }
if (user != null) { if (user != null) {
builder.setNeutralButton(R.string.user_dialog_button_delete) { _, _ -> builder.setNeutralButton(R.string.user_dialog_button_delete) { _, _ ->
if (this::listener.isInitialized && userId != null) { if (this::listener.isInitialized) {
listener.onDeleteUser(this, userId) listener.onDeleteUser(this, user!!.baseUrl)
} }
} }
} }
@ -131,7 +134,7 @@ class UserFragment : DialogFragment() {
val username = usernameView.text?.toString() ?: "" val username = usernameView.text?.toString() ?: ""
val password = passwordView.text?.toString() ?: "" val password = passwordView.text?.toString() ?: ""
if (user == null) { if (user == null) {
user = User(Random.nextLong(), baseUrl, username, password) user = User(baseUrl, username, password)
listener.onAddUser(this, user!!) listener.onAddUser(this, user!!)
} else { } else {
user = if (password.isNotEmpty()) { user = if (password.isNotEmpty()) {
@ -149,6 +152,7 @@ class UserFragment : DialogFragment() {
val password = passwordView.text?.toString() ?: "" val password = passwordView.text?.toString() ?: ""
if (user == null) { if (user == null) {
positiveButton.isEnabled = (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) positiveButton.isEnabled = (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
&& !baseUrlsInUse.contains(baseUrl)
&& username.isNotEmpty() && password.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty()
} else { } else {
positiveButton.isEnabled = username.isNotEmpty() // Unchanged if left blank positiveButton.isEnabled = username.isNotEmpty() // Unchanged if left blank
@ -157,21 +161,21 @@ class UserFragment : DialogFragment() {
companion object { companion object {
const val TAG = "NtfyUserFragment" const val TAG = "NtfyUserFragment"
private const val BUNDLE_USER_ID = "userId"
private const val BUNDLE_BASE_URL = "baseUrl" private const val BUNDLE_BASE_URL = "baseUrl"
private const val BUNDLE_USERNAME = "username" private const val BUNDLE_USERNAME = "username"
private const val BUNDLE_PASSWORD = "password" 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<String>): UserFragment {
val fragment = UserFragment() val fragment = UserFragment()
val args = Bundle() val args = Bundle()
args.putStringArrayList(BUNDLE_BASE_URLS_IN_USE, baseUrlsInUse)
if (user != null) { if (user != null) {
args.putLong(BUNDLE_USER_ID, user.id)
args.putString(BUNDLE_BASE_URL, user.baseUrl) args.putString(BUNDLE_BASE_URL, user.baseUrl)
args.putString(BUNDLE_USERNAME, user.username) args.putString(BUNDLE_USERNAME, user.username)
args.putString(BUNDLE_PASSWORD, user.password) args.putString(BUNDLE_PASSWORD, user.password)
fragment.arguments = args
} }
fragment.arguments = args
return fragment return fragment
} }
} }

View file

@ -67,7 +67,6 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
topic = topic, topic = topic,
instant = true, // No Firebase, always instant! instant = true, // No Firebase, always instant!
mutedUntil = 0, mutedUntil = 0,
authUserId = null, // FIXME add UP user in settings
upAppId = appId, upAppId = appId,
upConnectorToken = connectorToken, upConnectorToken = connectorToken,
totalCount = 0, totalCount = 0,

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:paddingLeft="16dp" android:paddingLeft="16dp"
@ -148,36 +148,31 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingTop="16dp" android:paddingTop="16dp"
android:paddingBottom="3dp" android:paddingBottom="3dp"
android:text="Login required" android:text="@string/add_dialog_login_title"
android:textAlignment="viewStart" android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp" android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/add_dialog_login_error_image"/> app:layout_constraintEnd_toStartOf="@id/add_dialog_login_error_image"/>
<TextView <TextView
android:text="This topic requires you to login. Please pick an existing user or type in a username and password." android:text="@string/add_dialog_login_description"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/add_dialog_login_description" android:layout_height="wrap_content" android:id="@+id/add_dialog_login_description"
android:paddingStart="4dp" android:paddingTop="3dp" app:layout_constraintStart_toStartOf="parent" android:paddingStart="4dp" android:paddingTop="3dp" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_login_title"/> app:layout_constraintTop_toBottomOf="@id/add_dialog_login_title" android:paddingEnd="4dp"/>
<Spinner
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/add_dialog_login_users_spinner"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_login_description" android:paddingStart="0dp"/>
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/add_dialog_login_username" android:id="@+id/add_dialog_login_username"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:hint="Username" android:layout_height="wrap_content" android:hint="@string/add_dialog_login_username_hint"
android:importantForAutofill="no" android:importantForAutofill="no"
android:maxLines="1" android:inputType="text" android:maxLength="64" android:maxLines="1" android:inputType="text" android:maxLength="64"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_login_users_spinner"/> android:layout_marginTop="10dp" app:layout_constraintTop_toBottomOf="@+id/add_dialog_login_description"/>
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/add_dialog_login_password" android:id="@+id/add_dialog_login_password"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:hint="Password" android:layout_height="wrap_content" android:hint="@string/add_dialog_login_password_hint"
android:importantForAutofill="no" android:importantForAutofill="no"
android:maxLines="1" android:inputType="textPassword" app:layout_constraintStart_toStartOf="parent" android:maxLines="1" android:inputType="textPassword" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"

View file

@ -14,6 +14,6 @@
<item android:id="@+id/detail_menu_test" android:title="@string/detail_menu_test"/> <item android:id="@+id/detail_menu_test" android:title="@string/detail_menu_test"/>
<item android:id="@+id/detail_menu_copy_url" android:title="@string/detail_menu_copy_url"/> <item android:id="@+id/detail_menu_copy_url" android:title="@string/detail_menu_copy_url"/>
<item android:id="@+id/detail_menu_clear" android:title="@string/detail_menu_clear"/> <item android:id="@+id/detail_menu_clear" android:title="@string/detail_menu_clear"/>
<item android:id="@+id/detail_menu_settings" android:title="@string/detail_menu_settings"/> <!--<item android:id="@+id/detail_menu_settings" android:title="@string/detail_menu_settings"/>-->
<item android:id="@+id/detail_menu_unsubscribe" android:title="@string/detail_menu_unsubscribe"/> <item android:id="@+id/detail_menu_unsubscribe" android:title="@string/detail_menu_unsubscribe"/>
</menu> </menu>

View file

@ -100,6 +100,10 @@
<string name="add_dialog_button_back">Back</string> <string name="add_dialog_button_back">Back</string>
<string name="add_dialog_button_login">Login</string> <string name="add_dialog_button_login">Login</string>
<string name="add_dialog_error_connection_failed">Connection failed: %1$s</string> <string name="add_dialog_error_connection_failed">Connection failed: %1$s</string>
<string name="add_dialog_login_title">Login required</string>
<string name="add_dialog_login_description">This topic requires you to login. Please type in a username and password.</string>
<string name="add_dialog_login_username_hint">Username</string>
<string name="add_dialog_login_password_hint">Password</string>
<string name="add_dialog_login_error_not_authorized">Login failed. User not authorized.</string> <string name="add_dialog_login_error_not_authorized">Login failed. User not authorized.</string>
<string name="add_dialog_login_new_user">New user</string> <string name="add_dialog_login_new_user">New user</string>
@ -259,7 +263,7 @@
<string name="settings_users_prefs_user_used_by_many">Used by topics %1$s</string> <string name="settings_users_prefs_user_used_by_many">Used by topics %1$s</string>
<string name="settings_users_prefs_user_add">Add users</string> <string name="settings_users_prefs_user_add">Add users</string>
<string name="settings_users_prefs_user_add_title">Add new user</string> <string name="settings_users_prefs_user_add_title">Add new user</string>
<string name="settings_users_prefs_user_add_summary">Create a new user that can be associated to topics. You can also create a new user when adding a topic.</string> <string name="settings_users_prefs_user_add_summary">Create a new user for a new server</string>
<string name="settings_unified_push_header">UnifiedPush</string> <string name="settings_unified_push_header">UnifiedPush</string>
<string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string> <string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string>
<string name="settings_unified_push_enabled_key">UnifiedPushEnabled</string> <string name="settings_unified_push_enabled_key">UnifiedPushEnabled</string>