streaming: implemented streaming for notifications

main
Alibek Omarov 4 years ago
parent 828198dab7
commit 55d3e9ed08
  1. 12
      app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json
  2. 3
      app/src/husky/res/values/strings.xml
  3. 2
      app/src/main/AndroidManifest.xml
  4. 53
      app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
  5. 4
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java
  6. 3
      app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt
  7. 1
      app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt
  8. 4
      app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt
  9. 1
      app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
  10. 4
      app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
  11. 4
      app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt
  12. 2
      app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt
  13. 20
      app/src/main/java/com/keylesspalace/tusky/entity/StreamEvent.kt
  14. 2
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
  15. 6
      app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt
  16. 4
      app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt
  17. 194
      app/src/main/java/com/keylesspalace/tusky/service/StreamingService.kt

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 25,
"identityHash": "7ab8482b8d5dcb97c4c8932f578879f2",
"identityHash": "ce7a96213f9e12e00a6ec21f3efaf547",
"entities": [
{
"tableName": "TootEntity",
@ -92,7 +92,7 @@
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsEmojiReactions` INTEGER NOT NULL, `notificationsChatMessages` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `defaultFormattingSyntax` TEXT NOT NULL)",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsStreamingEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsEmojiReactions` INTEGER NOT NULL, `notificationsChatMessages` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `defaultFormattingSyntax` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
@ -148,6 +148,12 @@
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsStreamingEnabled",
"columnName": "notificationsStreamingEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
@ -879,7 +885,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, '7ab8482b8d5dcb97c4c8932f578879f2')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ce7a96213f9e12e00a6ec21f3efaf547')"
]
}
}

@ -43,6 +43,9 @@
<string name="attachment_type_unknown">Attachment</string>
<string name="link">Link</string> <!-- Web Link -->
<string name="streaming_notification_name">Live notifications</string>
<string name="streaming_notification_description">Running live notifications for: </string>
<!-- REPLACEMENT FOR TUSKY STRINGS -->
<string name="action_toggle_visibility">Post visibility</string>

@ -168,6 +168,8 @@
<service android:name=".service.SendTootService" />
<service android:name=".service.StreamingService" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

@ -60,6 +60,7 @@ import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.service.StreamingService
import com.keylesspalace.tusky.util.*
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
@ -191,53 +192,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
// Setup push notifications
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
NotificationHelper.enablePullNotifications(this)
// Use when WorkManager doesn't want to work
/*
val accountList = accountManager.getAllAccountsOrderedByActive()
for (account in accountList) {
if (account.notificationsEnabled) {
try {
Log.d(TAG, "getting Notifications for " + account.fullName)
// don't care about withMuted because they are always silently ignored
val notificationsResponse = mastodonApi.notificationsWithAuth(
String.format("Bearer %s", account.accessToken),
account.domain, true,
setOf(Notification.Type.CHAT_MESSAGE.presentation)
).enqueue(object: Callback<List<Notification>> {
override fun onFailure(call: Call<List<Notification>>, t: Throwable) {
}
override fun onResponse(call: Call<List<Notification>>, response: Response<List<Notification>>) {
val notifications = response.body()
val newId = account.lastNotificationId
var newestId = ""
var isFirstOfBatch = true
notifications?.reversed()?.forEach { notification ->
val currentId = notification.id
if (newestId.isLessThan(currentId)) {
newestId = currentId
}
if (newId.isLessThan(currentId)) {
NotificationHelper.make(this@MainActivity, notification, account, isFirstOfBatch)
isFirstOfBatch = false
}
}
account.lastNotificationId = newestId
accountManager.saveAccount(account)
}
})
} catch (e: IOException) {
Log.w(TAG, "error receiving notifications", e)
}
if(accountManager.areNotificationsStreamingEnabled()) {
NotificationHelper.disablePullNotifications(this)
StreamingService.startStreaming(this)
} else {
StreamingService.stopStreaming(this)
NotificationHelper.enablePullNotifications(this)
}
*/
}
} else {
StreamingService.stopStreaming(this)
NotificationHelper.disablePullNotifications(this)
}
eventHub.events

@ -138,7 +138,7 @@ public class NotificationHelper {
/**
* by setting this as false, it's possible to test legacy notification channels on newer devices
*/
//public static final boolean NOTIFICATION_USE_CHANNELS = false;
// public static final boolean NOTIFICATION_USE_CHANNELS = false;
public static final boolean NOTIFICATION_USE_CHANNELS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
/**
@ -518,7 +518,7 @@ public class NotificationHelper {
PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS, TimeUnit.MILLISECONDS
)
.addTag(NOTIFICATION_PULL_TAG)
//.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build();
workManager.enqueue(workRequest);

@ -46,8 +46,7 @@ class NotificationWorker(
val notificationsResponse = mastodonApi.notificationsWithAuth(
String.format("Bearer %s", account.accessToken),
account.domain, true,
setOf(Notification.Type.CHAT_MESSAGE.presentation)
).execute()
Notification.Type.asStringList).execute()
val notifications = notificationsResponse.body()
if (notificationsResponse.isSuccessful && notifications != null) {
onNotificationsReceived(account, notifications)

@ -37,6 +37,7 @@ data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long,
var displayName: String = "",
var profilePictureUrl: String = "",
var notificationsEnabled: Boolean = true,
var notificationsStreamingEnabled: Boolean = true,
var notificationsMentioned: Boolean = true,
var notificationsFollowed: Boolean = true,
var notificationsFollowRequested: Boolean = false,

@ -184,6 +184,10 @@ class AccountManager @Inject constructor(db: AppDatabase) {
return accounts.any { it.notificationsEnabled }
}
fun areNotificationsStreamingEnabled() : Boolean {
return accounts.any { it.notificationsStreamingEnabled }
}
/**
* Finds an account by its database id
* @param accountId the id of the account

@ -372,6 +372,7 @@ public abstract class AppDatabase extends RoomDatabase {
"PRIMARY KEY (`localId`, `messageId`))");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `chatLimit` INTEGER");
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsChatMessages` INTEGER NOT NULL DEFAULT 1");
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsStreamingEnabled` INTEGER NOT NULL DEFAULT 1");
}
};
}

@ -60,9 +60,9 @@ class NetworkModule {
addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
//level = HttpLoggingInterceptor.Level.BASIC
level = HttpLoggingInterceptor.Level.BASIC
//level = HttpLoggingInterceptor.Level.HEADERS
level = HttpLoggingInterceptor.Level.BODY
//level = HttpLoggingInterceptor.Level.BODY
})
}
}

@ -19,6 +19,7 @@ import android.content.Context
import com.keylesspalace.tusky.service.SendTootService
import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.ServiceClientImpl
import com.keylesspalace.tusky.service.StreamingService
import dagger.Module
import dagger.Provides
import dagger.android.ContributesAndroidInjector
@ -28,6 +29,9 @@ abstract class ServicesModule {
@ContributesAndroidInjector
abstract fun contributesSendTootService(): SendTootService
@ContributesAndroidInjector
abstract fun contributesStreamingService(): StreamingService
@Module
companion object {
@Provides

@ -57,6 +57,8 @@ data class Notification(
return UNKNOWN
}
val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, POLL, EMOJI_REACTION, FOLLOW_REQUEST, CHAT_MESSAGE)
val asStringList = asList.map { it.presentation }
}
override fun toString(): String {

@ -0,0 +1,20 @@
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
data class StreamEvent (
val event: EventType,
val payload: String
) {
enum class EventType {
UNKNOWN,
@SerializedName("update")
UPDATE,
@SerializedName("notification")
NOTIFICATION,
@SerializedName("delete")
DELETE,
@SerializedName("filters_changed")
FILTERS_CHANGED;
}
}

@ -110,7 +110,7 @@ interface MastodonApi {
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@Query("with_muted") withMuted: Boolean?,
@Query("include_types[]") includeTypes: Set<String>?
@Query("include_types[]") includeTypes: List<String>?
): Call<List<Notification>>
@POST("api/v1/notifications/clear")

@ -52,7 +52,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
val senderIdentifier = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_IDENTIFIER)
val senderFullName = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME)
val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID)
val visibility = intent.getSerializableExtra(NotificationHelper.KEY_VISIBILITY) as Status.Visibility
val visibility = intent.getSerializableExtra(NotificationHelper.KEY_VISIBILITY)
val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER)
val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS)
val citedText = intent.getStringExtra(NotificationHelper.KEY_CITED_TEXT)
@ -93,7 +93,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
val composeIntent = ComposeActivity.startIntent(context, ComposeOptions(
inReplyToId = citedStatusId,
replyVisibility = visibility,
replyVisibility = visibility as Status.Visibility,
contentWarning = spoiler,
mentionedUsernames = mentions.toSet(),
replyingStatusAuthor = localAuthorId,
@ -114,7 +114,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
TootToSend(
text,
spoiler,
visibility.serverString(),
(visibility as Status.Visibility).serverString(),
false,
emptyList(),
emptyList(),

@ -8,9 +8,7 @@ import android.content.ClipData
import android.content.ClipDescription
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.Parcelable
import android.os.*
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat

@ -0,0 +1,194 @@
package com.keylesspalace.tusky.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Message
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.google.gson.Gson
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.StreamEvent
import com.keylesspalace.tusky.network.MastodonApi
import dagger.android.AndroidInjection
import okhttp3.*
import javax.inject.Inject
class StreamingService: Service(), Injectable {
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var gson: Gson
private val sockets: MutableMap<Long, WebSocket> = mutableMapOf()
private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onCreate() {
AndroidInjection.inject(this)
super.onCreate()
}
private fun stopStreamingForId(id: Long) {
if(id in sockets) {
sockets[id]!!.close(1000, null)
sockets.remove(id)
}
}
private fun stopStreaming() : Int {
for(sock in sockets) {
sock.value.close(1000, null)
}
sockets.clear()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
}
notificationManager.cancel(1337)
return START_NOT_STICKY
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if(intent.hasExtra(KEY_STOP_STREAMING)) {
Log.d(TAG, "Stopping stream")
return stopStreaming()
}
var description = getString(R.string.streaming_notification_description)
val accounts = accountManager.getAllAccountsOrderedByActive()
var count = 0
for(account in accounts) {
stopStreamingForId(account.id)
if(!account.notificationsStreamingEnabled)
continue
val endpoint = "wss://${account.domain}/api/v1/streaming/?access_token=${account.accessToken}&stream=user:notification"
val request = Request.Builder().url(endpoint).build()
val client = OkHttpClient.Builder().build()
Log.d(TAG, "Running stream for ${account.fullName}")
sockets[account.id] = client.newWebSocket(request, StreamingListener(this, gson, account))
description += "\n" + account.fullName
count++
}
if(count <= 0) {
Log.d(TAG, "No accounts. Stopping stream")
return stopStreaming()
}
if (NotificationHelper.NOTIFICATION_USE_CHANNELS) {
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.streaming_notification_name), NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(channel)
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.streaming_notification_name))
.setContentText(description)
.setOngoing(true)
.setColor(ContextCompat.getColor(this, R.color.tusky_blue))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
startForeground(1337, builder.build())
} else {
notificationManager.notify(1337, builder.build())
}
return START_NOT_STICKY
}
companion object {
val CHANNEL_ID = "streaming"
val KEY_STOP_STREAMING = "stop_streaming"
val TAG = "StreamingService"
@JvmStatic
private fun startForegroundService(ctx: Context, intent: Intent) {
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
}
@JvmStatic
fun startStreaming(context: Context) {
val intent = Intent(context, StreamingService::class.java)
Log.d(TAG, "Starting notifications streaming service...")
startForegroundService(context, intent)
}
@JvmStatic
fun stopStreaming(context: Context) {
val intent = Intent(context, StreamingService::class.java)
intent.putExtra(KEY_STOP_STREAMING, 123)
Log.d(TAG, "Stopping notifications streaming service...")
startForegroundService(context, intent)
}
}
class StreamingListener(val context: Context, val gson: Gson, val account: AccountEntity) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "Stream connected to: ${account.fullName}/user:notification")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "Stream closed for: ${account.fullName}/user:notification")
}
override fun onMessage(webSocket: WebSocket, text: String) {
val event = gson.fromJson(text, StreamEvent::class.java)
when(event.event) {
StreamEvent.EventType.NOTIFICATION -> {
val notification = gson.fromJson(event.payload, Notification::class.java)
NotificationHelper.make(context, notification, account, true)
}
else -> {
Log.d(TAG, "Unknown event type: ${event.event.toString()}")
}
}
super.onMessage(webSocket, text)
}
}
}
Loading…
Cancel
Save