Works and is not super ugly
This commit is contained in:
parent
73f610afa8
commit
4efdce54ef
7 changed files with 130 additions and 72 deletions
app/src/main
java/io/heckel/ntfy
res/values
|
@ -34,8 +34,8 @@ data class SubscriptionWithMetadata(
|
||||||
val topic: String,
|
val topic: String,
|
||||||
val instant: Boolean,
|
val instant: Boolean,
|
||||||
val mutedUntil: Long,
|
val mutedUntil: Long,
|
||||||
val upAppId: String,
|
val upAppId: String?,
|
||||||
val upConnectorToken: String,
|
val upConnectorToken: String?,
|
||||||
val totalCount: Int,
|
val totalCount: Int,
|
||||||
val newCount: Int,
|
val newCount: Int,
|
||||||
val lastActive: Long
|
val lastActive: Long
|
||||||
|
|
|
@ -283,8 +283,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
||||||
topic = topic,
|
topic = topic,
|
||||||
instant = instant,
|
instant = instant,
|
||||||
mutedUntil = 0,
|
mutedUntil = 0,
|
||||||
upAppId = "",
|
upAppId = null,
|
||||||
upConnectorToken = "",
|
upConnectorToken = null,
|
||||||
totalCount = 0,
|
totalCount = 0,
|
||||||
newCount = 0,
|
newCount = 0,
|
||||||
lastActive = Date().time/1000
|
lastActive = Date().time/1000
|
||||||
|
@ -314,11 +314,21 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
||||||
private fun onSubscriptionItemClick(subscription: Subscription) {
|
private fun onSubscriptionItemClick(subscription: Subscription) {
|
||||||
if (actionMode != null) {
|
if (actionMode != null) {
|
||||||
handleActionModeClick(subscription)
|
handleActionModeClick(subscription)
|
||||||
|
} else if (subscription.upAppId != null) { // Not UnifiedPush
|
||||||
|
displayUnifiedPushToast(subscription)
|
||||||
} else {
|
} else {
|
||||||
startDetailView(subscription)
|
startDetailView(subscription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun displayUnifiedPushToast(subscription: Subscription) {
|
||||||
|
runOnUiThread {
|
||||||
|
val appId = subscription.upAppId ?: ""
|
||||||
|
val toastMessage = getString(R.string.main_unified_push_toast, appId)
|
||||||
|
Toast.makeText(this@MainActivity, toastMessage, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun onSubscriptionItemLongClick(subscription: Subscription) {
|
private fun onSubscriptionItemLongClick(subscription: Subscription) {
|
||||||
if (actionMode == null) {
|
if (actionMode == null) {
|
||||||
beginActionMode(subscription)
|
beginActionMode(subscription)
|
||||||
|
@ -415,7 +425,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
||||||
val dialog = builder
|
val dialog = builder
|
||||||
.setMessage(R.string.main_action_mode_delete_dialog_message)
|
.setMessage(R.string.main_action_mode_delete_dialog_message)
|
||||||
.setPositiveButton(R.string.main_action_mode_delete_dialog_permanently_delete) { _, _ ->
|
.setPositiveButton(R.string.main_action_mode_delete_dialog_permanently_delete) { _, _ ->
|
||||||
adapter.selected.map { viewModel.remove(it) }
|
adapter.selected.map { subscriptionId -> viewModel.remove(this, subscriptionId) }
|
||||||
finishActionMode()
|
finishActionMode()
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.main_action_mode_delete_dialog_cancel) { _, _ ->
|
.setNegativeButton(R.string.main_action_mode_delete_dialog_cancel) { _, _ ->
|
||||||
|
|
|
@ -55,7 +55,9 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
|
||||||
|
|
||||||
fun bind(subscription: Subscription) {
|
fun bind(subscription: Subscription) {
|
||||||
this.subscription = subscription
|
this.subscription = subscription
|
||||||
var statusMessage = if (subscription.totalCount == 1) {
|
var statusMessage = if (subscription.upAppId != null) {
|
||||||
|
context.getString(R.string.main_item_status_unified_push, subscription.upAppId)
|
||||||
|
} else if (subscription.totalCount == 1) {
|
||||||
context.getString(R.string.main_item_status_text_one, subscription.totalCount)
|
context.getString(R.string.main_item_status_text_one, subscription.totalCount)
|
||||||
} else {
|
} else {
|
||||||
context.getString(R.string.main_item_status_text_not_one, subscription.totalCount)
|
context.getString(R.string.main_item_status_text_not_one, subscription.totalCount)
|
||||||
|
@ -82,11 +84,11 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
|
||||||
notificationDisabledUntilImageView.visibility = if (subscription.mutedUntil > 1L) View.VISIBLE else View.GONE
|
notificationDisabledUntilImageView.visibility = if (subscription.mutedUntil > 1L) View.VISIBLE else View.GONE
|
||||||
notificationDisabledForeverImageView.visibility = if (subscription.mutedUntil == 1L) View.VISIBLE else View.GONE
|
notificationDisabledForeverImageView.visibility = if (subscription.mutedUntil == 1L) View.VISIBLE else View.GONE
|
||||||
instantImageView.visibility = if (subscription.instant) View.VISIBLE else View.GONE
|
instantImageView.visibility = if (subscription.instant) View.VISIBLE else View.GONE
|
||||||
if (subscription.newCount > 0) {
|
if (subscription.upAppId != null || subscription.newCount == 0) {
|
||||||
|
newItemsView.visibility = View.GONE
|
||||||
|
} else {
|
||||||
newItemsView.visibility = View.VISIBLE
|
newItemsView.visibility = View.VISIBLE
|
||||||
newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+"
|
newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+"
|
||||||
} else {
|
|
||||||
newItemsView.visibility = View.GONE
|
|
||||||
}
|
}
|
||||||
itemView.setOnClickListener { onClick(subscription) }
|
itemView.setOnClickListener { onClick(subscription) }
|
||||||
itemView.setOnLongClickListener { onLongClick(subscription); true }
|
itemView.setOnLongClickListener { onLongClick(subscription); true }
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
package io.heckel.ntfy.ui
|
package io.heckel.ntfy.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.heckel.ntfy.data.*
|
import io.heckel.ntfy.data.*
|
||||||
|
import io.heckel.ntfy.up.Distributor
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.collections.List
|
import kotlin.collections.List
|
||||||
|
@ -22,7 +24,12 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
|
||||||
repository.addSubscription(subscription)
|
repository.addSubscription(subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
|
fun remove(context: Context, subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val subscription = repository.getSubscription(subscriptionId) ?: return@launch
|
||||||
|
if (subscription.upAppId != null && subscription.upConnectorToken != null) {
|
||||||
|
val distributor = Distributor(context)
|
||||||
|
distributor.sendUnregistered(subscription.upAppId, subscription.upConnectorToken)
|
||||||
|
}
|
||||||
repository.removeAllNotifications(subscriptionId)
|
repository.removeAllNotifications(subscriptionId)
|
||||||
repository.removeSubscription(subscriptionId)
|
repository.removeSubscription(subscriptionId)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import android.content.Intent
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import io.heckel.ntfy.R
|
import io.heckel.ntfy.R
|
||||||
import io.heckel.ntfy.app.Application
|
import io.heckel.ntfy.app.Application
|
||||||
|
import io.heckel.ntfy.data.Repository
|
||||||
import io.heckel.ntfy.data.Subscription
|
import io.heckel.ntfy.data.Subscription
|
||||||
import io.heckel.ntfy.ui.SubscriberManager
|
import io.heckel.ntfy.ui.SubscriberManager
|
||||||
import io.heckel.ntfy.util.randomString
|
import io.heckel.ntfy.util.randomString
|
||||||
|
@ -17,71 +18,99 @@ import kotlin.random.Random
|
||||||
|
|
||||||
class BroadcastReceiver : android.content.BroadcastReceiver() {
|
class BroadcastReceiver : android.content.BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
when (intent!!.action) {
|
if (context == null || intent == null) {
|
||||||
ACTION_REGISTER -> {
|
return
|
||||||
val appId = intent.getStringExtra(EXTRA_APPLICATION) ?: ""
|
|
||||||
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: ""
|
|
||||||
Log.d(TAG, "Register: app=$appId, connectorToken=$connectorToken")
|
|
||||||
if (appId.isBlank()) {
|
|
||||||
Log.w(TAG, "Trying to register an app without packageName")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val baseUrl = context!!.getString(R.string.app_base_url) // FIXME
|
|
||||||
val topic = "up" + randomString(TOPIC_LENGTH)
|
|
||||||
val endpoint = topicUrlUp(baseUrl, topic)
|
|
||||||
val app = context!!.applicationContext as Application
|
|
||||||
val repository = app.repository
|
|
||||||
val distributor = Distributor(app)
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
|
|
||||||
if (existingSubscription != null) {
|
|
||||||
distributor.sendRegistrationRefused(appId, connectorToken)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
val subscription = Subscription(
|
|
||||||
id = Random.nextLong(),
|
|
||||||
baseUrl = baseUrl,
|
|
||||||
topic = topic,
|
|
||||||
instant = true, // No Firebase, always instant!
|
|
||||||
mutedUntil = 0,
|
|
||||||
upAppId = appId,
|
|
||||||
upConnectorToken = connectorToken,
|
|
||||||
totalCount = 0,
|
|
||||||
newCount = 0,
|
|
||||||
lastActive = Date().time/1000
|
|
||||||
)
|
|
||||||
repository.addSubscription(subscription)
|
|
||||||
val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus()
|
|
||||||
val subscriberManager = SubscriberManager(app)
|
|
||||||
subscriberManager.refreshService(subscriptionIdsWithInstantStatus)
|
|
||||||
distributor.sendEndpoint(appId, connectorToken, endpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ACTION_UNREGISTER -> {
|
|
||||||
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: ""
|
|
||||||
Log.d(TAG, "Unregister: connectorToken=$connectorToken")
|
|
||||||
val app = context!!.applicationContext as Application
|
|
||||||
val repository = app.repository
|
|
||||||
val distributor = Distributor(app)
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
|
|
||||||
if (existingSubscription == null) {
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
repository.removeSubscription(existingSubscription.id)
|
|
||||||
val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus()
|
|
||||||
val subscriberManager = SubscriberManager(app)
|
|
||||||
subscriberManager.refreshService(subscriptionIdsWithInstantStatus)
|
|
||||||
existingSubscription.upAppId?.let { appId ->
|
|
||||||
distributor.sendUnregistered(appId, connectorToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_REGISTER -> register(context, intent)
|
||||||
|
ACTION_UNREGISTER -> unregister(context, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun register(context: Context, intent: Intent) {
|
||||||
|
val appId = intent.getStringExtra(EXTRA_APPLICATION) ?: return
|
||||||
|
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return
|
||||||
|
val app = context.applicationContext as Application
|
||||||
|
val repository = app.repository
|
||||||
|
val distributor = Distributor(app)
|
||||||
|
Log.d(TAG, "REGISTER received for app $appId (connectorToken=$connectorToken)")
|
||||||
|
if (appId.isBlank()) {
|
||||||
|
Log.w(TAG, "Refusing registration: empty application")
|
||||||
|
distributor.sendRegistrationRefused(appId, connectorToken)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
|
||||||
|
if (existingSubscription != null) {
|
||||||
|
if (existingSubscription.upAppId == appId) {
|
||||||
|
val endpoint = topicUrlUp(existingSubscription.baseUrl, existingSubscription.topic)
|
||||||
|
Log.d(TAG, "Subscription with connectorToken $connectorToken exists. Sending endpoint $endpoint.")
|
||||||
|
distributor.sendEndpoint(appId, connectorToken, endpoint)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Subscription with connectorToken $connectorToken exists for a different app. Refusing registration.")
|
||||||
|
distributor.sendRegistrationRefused(appId, connectorToken)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val baseUrl = context.getString(R.string.app_base_url) // FIXME
|
||||||
|
val topic = UP_PREFIX + randomString(TOPIC_LENGTH)
|
||||||
|
val endpoint = topicUrlUp(baseUrl, topic)
|
||||||
|
val subscription = Subscription(
|
||||||
|
id = Random.nextLong(),
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
topic = topic,
|
||||||
|
instant = true, // No Firebase, always instant!
|
||||||
|
mutedUntil = 0,
|
||||||
|
upAppId = appId,
|
||||||
|
upConnectorToken = connectorToken,
|
||||||
|
totalCount = 0,
|
||||||
|
newCount = 0,
|
||||||
|
lastActive = Date().time/1000
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add subscription
|
||||||
|
Log.d(TAG, "Adding subscription with for app $appId (connectorToken $connectorToken): $subscription")
|
||||||
|
repository.addSubscription(subscription)
|
||||||
|
distributor.sendEndpoint(appId, connectorToken, endpoint)
|
||||||
|
|
||||||
|
// Refresh (and maybe start) foreground service
|
||||||
|
refreshSubscriberService(app, repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregister(context: Context, intent: Intent) {
|
||||||
|
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return
|
||||||
|
val app = context.applicationContext as Application
|
||||||
|
val repository = app.repository
|
||||||
|
val distributor = Distributor(app)
|
||||||
|
Log.d(TAG, "UNREGISTER received (connectorToken=$connectorToken)")
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
|
||||||
|
if (existingSubscription == null) {
|
||||||
|
Log.d(TAG, "Subscription with connectorToken $connectorToken does not exist. Ignoring.")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove subscription
|
||||||
|
Log.d(TAG, "Removing subscription ${existingSubscription.id} with connectorToken $connectorToken")
|
||||||
|
repository.removeSubscription(existingSubscription.id)
|
||||||
|
existingSubscription.upAppId?.let { appId -> distributor.sendUnregistered(appId, connectorToken) }
|
||||||
|
|
||||||
|
// Refresh (and maybe stop) foreground service
|
||||||
|
refreshSubscriberService(app, repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshSubscriberService(context: Context, repository: Repository) {
|
||||||
|
Log.d(TAG, "Refreshing subscriber service")
|
||||||
|
val subscriptionIdsWithInstantStatus = repository.getSubscriptionIdsWithInstantStatus()
|
||||||
|
val subscriberManager = SubscriberManager(context)
|
||||||
|
subscriberManager.refreshService(subscriptionIdsWithInstantStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NtfyUpBroadcastRecv"
|
private const val TAG = "NtfyUpBroadcastRecv"
|
||||||
|
private const val UP_PREFIX = "up"
|
||||||
private const val TOPIC_LENGTH = 16
|
private const val TOPIC_LENGTH = 16
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,9 +2,11 @@ package io.heckel.ntfy.up
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
class Distributor(val context: Context) {
|
class Distributor(val context: Context) {
|
||||||
fun sendMessage(app: String, connectorToken: String, message: String) {
|
fun sendMessage(app: String, connectorToken: String, message: String) {
|
||||||
|
Log.d(TAG, "Sending MESSAGE to $app (token=$connectorToken): $message")
|
||||||
val broadcastIntent = Intent()
|
val broadcastIntent = Intent()
|
||||||
broadcastIntent.`package` = app
|
broadcastIntent.`package` = app
|
||||||
broadcastIntent.action = ACTION_MESSAGE
|
broadcastIntent.action = ACTION_MESSAGE
|
||||||
|
@ -14,6 +16,7 @@ class Distributor(val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendEndpoint(app: String, connectorToken: String, endpoint: String) {
|
fun sendEndpoint(app: String, connectorToken: String, endpoint: String) {
|
||||||
|
Log.d(TAG, "Sending NEW_ENDPOINT to $app (token=$connectorToken): $endpoint")
|
||||||
val broadcastIntent = Intent()
|
val broadcastIntent = Intent()
|
||||||
broadcastIntent.`package` = app
|
broadcastIntent.`package` = app
|
||||||
broadcastIntent.action = ACTION_NEW_ENDPOINT
|
broadcastIntent.action = ACTION_NEW_ENDPOINT
|
||||||
|
@ -23,6 +26,7 @@ class Distributor(val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendUnregistered(app: String, connectorToken: String) {
|
fun sendUnregistered(app: String, connectorToken: String) {
|
||||||
|
Log.d(TAG, "Sending UNREGISTERED to $app (token=$connectorToken)")
|
||||||
val broadcastIntent = Intent()
|
val broadcastIntent = Intent()
|
||||||
broadcastIntent.`package` = app
|
broadcastIntent.`package` = app
|
||||||
broadcastIntent.action = ACTION_UNREGISTERED
|
broadcastIntent.action = ACTION_UNREGISTERED
|
||||||
|
@ -31,10 +35,15 @@ class Distributor(val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendRegistrationRefused(app: String, connectorToken: String) {
|
fun sendRegistrationRefused(app: String, connectorToken: String) {
|
||||||
|
Log.d(TAG, "Sending REGISTRATION_REFUSED to $app (token=$connectorToken)")
|
||||||
val broadcastIntent = Intent()
|
val broadcastIntent = Intent()
|
||||||
broadcastIntent.`package` = app
|
broadcastIntent.`package` = app
|
||||||
broadcastIntent.action = ACTION_REGISTRATION_REFUSED
|
broadcastIntent.action = ACTION_REGISTRATION_REFUSED
|
||||||
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
|
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
|
||||||
context.sendBroadcast(broadcastIntent)
|
context.sendBroadcast(broadcastIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NtfyUpDistributor"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,6 +47,7 @@
|
||||||
<string name="main_item_status_text_one">%1$d notification</string>
|
<string name="main_item_status_text_one">%1$d notification</string>
|
||||||
<string name="main_item_status_text_not_one">%1$d notifications</string>
|
<string name="main_item_status_text_not_one">%1$d notifications</string>
|
||||||
<string name="main_item_status_reconnecting">reconnecting …</string>
|
<string name="main_item_status_reconnecting">reconnecting …</string>
|
||||||
|
<string name="main_item_status_unified_push">%1$s (UnifiedPush)</string>
|
||||||
<string name="main_item_date_yesterday">Yesterday</string>
|
<string name="main_item_date_yesterday">Yesterday</string>
|
||||||
<string name="main_add_button_description">Add subscription</string>
|
<string name="main_add_button_description">Add subscription</string>
|
||||||
<string name="main_no_subscriptions_text">It looks like you don\'t have any subscriptions yet.</string>
|
<string name="main_no_subscriptions_text">It looks like you don\'t have any subscriptions yet.</string>
|
||||||
|
@ -54,8 +55,8 @@
|
||||||
Click the button below to create or subscribe to a topic. After that, you can send
|
Click the button below to create or subscribe to a topic. After that, you can send
|
||||||
messages via PUT or POST and you\'ll receive notifications on your phone.
|
messages via PUT or POST and you\'ll receive notifications on your phone.
|
||||||
</string>
|
</string>
|
||||||
<string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.
|
<string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string>
|
||||||
</string>
|
<string name="main_unified_push_toast">Subscription is managed by %1$s via UnifiedPush</string>
|
||||||
|
|
||||||
<!-- Add dialog -->
|
<!-- Add dialog -->
|
||||||
<string name="add_dialog_title">Subscribe to topic</string>
|
<string name="add_dialog_title">Subscribe to topic</string>
|
||||||
|
|
Loading…
Add table
Reference in a new issue