diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json index eb29ed08..ce58a97a 100644 --- a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json @@ -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')" ] } } \ No newline at end of file diff --git a/app/src/husky/res/values/strings.xml b/app/src/husky/res/values/strings.xml index 56ad4784..c913f461 100644 --- a/app/src/husky/res/values/strings.xml +++ b/app/src/husky/res/values/strings.xml @@ -43,6 +43,9 @@ Attachment Link + + Live notifications + Running live notifications for: Post visibility diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 77aaa040..217fde72 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -168,6 +168,8 @@ + + > { - override fun onFailure(call: Call>, t: Throwable) { - - } - - override fun onResponse(call: Call>, response: Response>) { - 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 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index 24806409..c1c51ad7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -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); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt index 7a38e8d1..8ee2c896 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationWorker.kt @@ -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) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 4b5ed2d8..01cd8ac1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -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, diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index 5293bd03..5a72411e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -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 diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 878cac2e..89af2df1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -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"); } }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 6d81b2ff..64611d45 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -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 }) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt index 5f649554..a2d6d469 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt @@ -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 diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index 12e1a374..e057264c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -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 { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StreamEvent.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StreamEvent.kt new file mode 100644 index 00000000..ce761ed1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StreamEvent.kt @@ -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; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index d7ec1ed9..7cf54c22 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -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? + @Query("include_types[]") includeTypes: List? ): Call> @POST("api/v1/notifications/clear") diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 54969868..d5aa9974 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -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(), diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt index 44587454..3991e616 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt @@ -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 diff --git a/app/src/main/java/com/keylesspalace/tusky/service/StreamingService.kt b/app/src/main/java/com/keylesspalace/tusky/service/StreamingService.kt new file mode 100644 index 00000000..1b7d0676 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/service/StreamingService.kt @@ -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 = 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) + } + + } + +} \ No newline at end of file