Compare commits

...

22 Commits

Author SHA1 Message Date
63253e9cae Merge pull request 'Thumbnail generation on download and Android 13 fixes' (#329) from feat/thumbnails into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/329
2023-09-04 12:59:11 +00:00
26dafb4e9e chore(docs): Update the changelogs more 2023-09-03 22:12:17 +02:00
111c66aa6a chore(docs): Update changelog 2023-09-03 22:07:54 +02:00
5b03fc9b47 fix(service): Various fixes
- Fix a fourth notificatio channel appearing
- Pass a locale to the service on startup to prevent misnamed
  notification channels
2023-09-03 22:03:13 +02:00
672ae736d3 chore(docs): Mention ktlint for Android code 2023-09-03 16:40:03 +02:00
e37db3d00c fix(service): Fix typo in JID migration 2023-09-03 16:34:35 +02:00
919ed6f0a1 chore(android): Format with ktlint 2023-09-03 14:52:21 +02:00
79867e4eaa chore(all): Update moxplatform 2023-09-03 13:51:59 +02:00
99600bafb0 fix(ui,service): Merge ConversationExited with sending a gone 2023-09-03 00:03:23 +02:00
59aad79aa0 feat(service): Show video thumbnails in the notification 2023-09-02 20:53:49 +02:00
6fc4672a6e fix(service,ui): Fix notifications not updating 2023-09-02 20:17:04 +02:00
478c639ae7 feat(android): Refactor a little bit
Also, add VIBRATE permission.
2023-09-02 13:06:46 +02:00
7f2c978736 fix(service): Bring back notification tap events 2023-09-02 13:03:24 +02:00
f472239102 fix(ui): Replace context.read with GetIt 2023-08-30 21:00:27 +02:00
f2844122c0 chore(ui,service): Rename getVideoThumbnailPath 2023-08-30 20:59:14 +02:00
7781b12dac fix(service): Ensure that the correct locale is used 2023-08-30 20:21:16 +02:00
1e795b8b10 fix(service): Translate the foreground service info 2023-08-30 20:19:32 +02:00
7d0896d84f fix(service): Fix message channel name 2023-08-30 16:05:40 +02:00
fc0aade0ae fix(all): Remove Awesome Notifications 2023-08-30 16:04:39 +02:00
d969622675 feat(service): Replace the notification channels to apply new options 2023-08-30 15:51:51 +02:00
23839b6ec6 fix(service,ui): Fix notification issues 2023-08-26 23:28:49 +02:00
0b120c1e9c fix(service): Fix a fresh start on Android 13 2023-08-26 22:57:21 +02:00
53 changed files with 2625 additions and 448 deletions

View File

@ -7,7 +7,7 @@ line-length=72
[title-trailing-punctuation]
[title-hard-tab]
[title-match-regex]
regex=^((feat|fix|chore|refactor)\((service|ui|shared|all|tests|i18n|docs|flake)+(,(service|ui|shared|all|tests|i18n|docs|flake))*\)|release): [A-Z0-9].*$
regex=^((feat|fix|chore|refactor)\((service|ui|shared|all|tests|i18n|docs|flake|android|ios|linux|windows|macos)+(,(service|ui|shared|all|tests|i18n|docs|flake|android|ios|linux|windows|macos))*\)|release): [A-Z0-9].*$
[body-trailing-whitespace]

View File

@ -56,6 +56,12 @@ Before creating a pull request, please make sure you checked every item on the f
If you think that your code is ready for a pull request, but you are not sure if it is ready, prefix the PR's title with "WIP: ", so that discussion
can happen there. If you think your PR is ready for review, remove the "WIP: " prefix.
### Android
In case you modified the Android-native code, please also make sure that you checked every item on the following checklist:
- [ ] I checked that [ktlint](https://github.com/pinterest/ktlint) is not showing any linting issues (`ktlint android/app/src/main/kotlin/org/moxxy/moxxyv2/ '!android/app/src/main/kotlin/org/moxxy/moxxyv2/Api.kt'`)
### Tips
#### `data_classes.yaml`

View File

@ -14,3 +14,4 @@ analyzer:
- "**/*.freezed.dart"
- "**/*.moxxy.dart"
- "lib/i18n/*.dart"
- "pigeon/api.dart"

View File

@ -1,33 +1,35 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.moxxy.moxxyv2">
<application
android:label="Moxxy"
<application
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:label="Moxxy">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Allow receiving share intents for all kinds of things -->
<!-- Allow receiving share intents for all kinds of things -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@ -47,20 +49,37 @@
android:name="android.app.shortcuts"
android:resource="@xml/share_targets" />
</activity>
<provider
android:name="org.moxxy.moxxyv2.notifications.MoxxyFileProvider"
android:authorities="org.moxxy.moxxyv2.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<receiver android:name=".notifications.NotificationReceiver" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,666 @@
// Autogenerated from Pigeon (v11.0.1), do not edit directly.
// See also: https://pub.dev/packages/pigeon
package org.moxxy.moxxyv2
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
private fun wrapError(exception: Throwable): List<Any?> {
if (exception is FlutterError) {
return listOf(
exception.code,
exception.message,
exception.details
)
} else {
return listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
enum class NotificationIcon(val raw: Int) {
WARNING(0),
ERROR(1),
NONE(2);
companion object {
fun ofRaw(raw: Int): NotificationIcon? {
return values().firstOrNull { it.raw == raw }
}
}
}
enum class NotificationEventType(val raw: Int) {
MARKASREAD(0),
REPLY(1),
OPEN(2);
companion object {
fun ofRaw(raw: Int): NotificationEventType? {
return values().firstOrNull { it.raw == raw }
}
}
}
enum class NotificationChannelImportance(val raw: Int) {
MIN(0),
HIGH(1),
DEFAULT(2);
companion object {
fun ofRaw(raw: Int): NotificationChannelImportance? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class NotificationMessageContent (
/** The textual body of the message. */
val body: String? = null,
/** The path and mime type of the media to show. */
val mime: String? = null,
val path: String? = null
) {
companion object {
@Suppress("UNCHECKED_CAST")
fun fromList(list: List<Any?>): NotificationMessageContent {
val body = list[0] as String?
val mime = list[1] as String?
val path = list[2] as String?
return NotificationMessageContent(body, mime, path)
}
}
fun toList(): List<Any?> {
return listOf<Any?>(
body,
mime,
path,
)
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class NotificationMessage (
/** The grouping key for the notification. */
val groupId: String? = null,
/** The sender of the message. */
val sender: String? = null,
/** The jid of the sender. */
val jid: String? = null,
/** The body of the message. */
val content: NotificationMessageContent,
/** Milliseconds since epoch. */
val timestamp: Long,
/** The path to the avatar to use */
val avatarPath: String? = null
) {
companion object {
@Suppress("UNCHECKED_CAST")
fun fromList(list: List<Any?>): NotificationMessage {
val groupId = list[0] as String?
val sender = list[1] as String?
val jid = list[2] as String?
val content = NotificationMessageContent.fromList(list[3] as List<Any?>)
val timestamp = list[4].let { if (it is Int) it.toLong() else it as Long }
val avatarPath = list[5] as String?
return NotificationMessage(groupId, sender, jid, content, timestamp, avatarPath)
}
}
fun toList(): List<Any?> {
return listOf<Any?>(
groupId,
sender,
jid,
content.toList(),
timestamp,
avatarPath,
)
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class MessagingNotification (
/** The title of the conversation. */
val title: String,
/** The id of the notification. */
val id: Long,
/** The id of the notification channel the notification should appear on. */
val channelId: String,
/** The JID of the chat in which the notifications happen. */
val jid: String,
/** Messages to show. */
val messages: List<NotificationMessage?>,
/** Flag indicating whether this notification is from a groupchat or not. */
val isGroupchat: Boolean,
/** The id for notification grouping. */
val groupId: String? = null,
/** Additional data to include. */
val extra: Map<String?, String?>? = null
) {
companion object {
@Suppress("UNCHECKED_CAST")
fun fromList(list: List<Any?>): MessagingNotification {
val title = list[0] as String
val id = list[1].let { if (it is Int) it.toLong() else it as Long }
val channelId = list[2] as String
val jid = list[3] as String
val messages = list[4] as List<NotificationMessage?>
val isGroupchat = list[5] as Boolean
val groupId = list[6] as String?
val extra = list[7] as Map<String?, String?>?
return MessagingNotification(title, id, channelId, jid, messages, isGroupchat, groupId, extra)
}
}
fun toList(): List<Any?> {
return listOf<Any?>(
title,
id,
channelId,
jid,
messages,
isGroupchat,
groupId,
extra,
)
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class RegularNotification (
/** The title of the notification. */
val title: String,
/** The body of the notification. */
val body: String,
/** The id of the channel to show the notification on. */
val channelId: String,
/** The id for notification grouping. */
val groupId: String? = null,
/** The id of the notification. */
val id: Long,
/** The icon to use. */
val icon: NotificationIcon
) {
companion object {
@Suppress("UNCHECKED_CAST")
fun fromList(list: List<Any?>): RegularNotification {
val title = list[0] as String
val body = list[1] as String
val channelId = list[2] as String
val groupId = list[3] as String?
val id = list[4].let { if (it is Int) it.toLong() else it as Long }
val icon = NotificationIcon.ofRaw(list[5] as Int)!!
return RegularNotification(title, body, channelId, groupId, id, icon)
}
}
fun toList(): List<Any?> {
return listOf<Any?>(
title,
body,
channelId,
groupId,
id,
icon.raw,
)
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class NotificationEvent (
/** The notification id. */
val id: Long,
/** The JID the notification was for. */
val jid: String,
/** The type of event. */
val type: NotificationEventType,
/**
* An optional payload.
* - type == NotificationType.reply: The reply message text.
* Otherwise: undefined.
*/
val payload: String? = null,
/** Extra data. Only set when type == NotificationType.reply. */
val extra: Map<String?, String?>? = null
) {
companion object {
@Suppress("UNCHECKED_CAST")
fun fromList(list: List<Any?>): NotificationEvent {
val id = list[0].let { if (it is Int) it.toLong() else it as Long }
val jid = list[1] as String
val type = NotificationEventType.ofRaw(list[2] as Int)!!
val payload = list[3] as String?
val extra = list[4] as Map<String?, String?>?
return NotificationEvent(id, jid, type, payload, extra)
}
}
fun toList(): List<Any?> {
return listOf<Any?>(
id,
jid,
type.raw,
payload,
extra,
)
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class NotificationI18nData (
/** The content of the reply button. */
val reply: String,
/** The content of the "mark as read" button. */
val markAsRead: String,
/** The text to show when *you* reply. */
val you: String
) {
companion object {
@Suppress("UNCHECKED_CAST")
fun fromList(list: List<Any?>): NotificationI18nData {
val reply = list[0] as String
val markAsRead = list[1] as String
val you = list[2] as String
return NotificationI18nData(reply, markAsRead, you)
}
}
fun toList(): List<Any?> {
return listOf<Any?>(
reply,
markAsRead,
you,
)
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class NotificationGroup (
val id: String,
val description: String
) {
companion object {
@Suppress("UNCHECKED_CAST")
fun fromList(list: List<Any?>): NotificationGroup {
val id = list[0] as String
val description = list[1] as String
return NotificationGroup(id, description)
}
}
fun toList(): List<Any?> {
return listOf<Any?>(
id,
description,
)
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class NotificationChannel (
val title: String,
val description: String,
val id: String,
val importance: NotificationChannelImportance,
val showBadge: Boolean,
val groupId: String? = null,
val vibration: Boolean,
val enableLights: Boolean
) {
companion object {
@Suppress("UNCHECKED_CAST")
fun fromList(list: List<Any?>): NotificationChannel {
val title = list[0] as String
val description = list[1] as String
val id = list[2] as String
val importance = NotificationChannelImportance.ofRaw(list[3] as Int)!!
val showBadge = list[4] as Boolean
val groupId = list[5] as String?
val vibration = list[6] as Boolean
val enableLights = list[7] as Boolean
return NotificationChannel(title, description, id, importance, showBadge, groupId, vibration, enableLights)
}
}
fun toList(): List<Any?> {
return listOf<Any?>(
title,
description,
id,
importance.raw,
showBadge,
groupId,
vibration,
enableLights,
)
}
}
@Suppress("UNCHECKED_CAST")
private object MoxxyApiCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
128.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
MessagingNotification.fromList(it)
}
}
129.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
NotificationChannel.fromList(it)
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
NotificationEvent.fromList(it)
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
NotificationGroup.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
NotificationI18nData.fromList(it)
}
}
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
NotificationMessage.fromList(it)
}
}
134.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
NotificationMessageContent.fromList(it)
}
}
135.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
RegularNotification.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is MessagingNotification -> {
stream.write(128)
writeValue(stream, value.toList())
}
is NotificationChannel -> {
stream.write(129)
writeValue(stream, value.toList())
}
is NotificationEvent -> {
stream.write(130)
writeValue(stream, value.toList())
}
is NotificationGroup -> {
stream.write(131)
writeValue(stream, value.toList())
}
is NotificationI18nData -> {
stream.write(132)
writeValue(stream, value.toList())
}
is NotificationMessage -> {
stream.write(133)
writeValue(stream, value.toList())
}
is NotificationMessageContent -> {
stream.write(134)
writeValue(stream, value.toList())
}
is RegularNotification -> {
stream.write(135)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface MoxxyApi {
/** Notification APIs */
fun createNotificationGroups(groups: List<NotificationGroup>)
fun deleteNotificationGroups(ids: List<String>)
fun createNotificationChannels(channels: List<NotificationChannel>)
fun deleteNotificationChannels(ids: List<String>)
fun showMessagingNotification(notification: MessagingNotification)
fun showNotification(notification: RegularNotification)
fun dismissNotification(id: Long)
fun setNotificationSelfAvatar(path: String)
fun setNotificationI18n(data: NotificationI18nData)
fun notificationStub(event: NotificationEvent)
companion object {
/** The codec used by MoxxyApi. */
val codec: MessageCodec<Any?> by lazy {
MoxxyApiCodec
}
/** Sets up an instance of `MoxxyApi` to handle messages through the `binaryMessenger`. */
@Suppress("UNCHECKED_CAST")
fun setUp(binaryMessenger: BinaryMessenger, api: MoxxyApi?) {
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxyv2.MoxxyApi.createNotificationGroups", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val groupsArg = args[0] as List<NotificationGroup>
var wrapped: List<Any?>
try {
api.createNotificationGroups(groupsArg)
wrapped = listOf<Any?>(null)
} catch (exception: Throwable) {
wrapped = wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxyv2.MoxxyApi.deleteNotificationGroups", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val idsArg = args[0] as List<String>
var wrapped: List<Any?>
try {
api.deleteNotificationGroups(idsArg)
wrapped = listOf<Any?>(null)
} catch (exception: Throwable) {
wrapped = wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxyv2.MoxxyApi.createNotificationChannels", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val channelsArg = args[0] as List<NotificationChannel>
var wrapped: List<Any?>
try {
api.createNotificationChannels(channelsArg)
wrapped = listOf<Any?>(null)
} catch (exception: Throwable) {
wrapped = wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxyv2.MoxxyApi.deleteNotificationChannels", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val idsArg = args[0] as List<String>
var wrapped: List<Any?>
try {
api.deleteNotificationChannels(idsArg)
wrapped = listOf<Any?>(null)
} catch (exception: Throwable) {
wrapped = wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxyv2.MoxxyApi.showMessagingNotification", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val notificationArg = args[0] as MessagingNotification
var wrapped: List<Any?>
try {
api.showMessagingNotification(notificationArg)
wrapped = listOf<Any?>(null)
} catch (exception: Throwable) {
wrapped = wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxyv2.MoxxyApi.showNotification", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val notificationArg = args[0] as RegularNotification
var wrapped: List<Any?>
try {
api.showNotification(notificationArg)
wrapped = listOf<Any?>(null)
} catch (exception: Throwable) {
wrapped = wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxyv2.MoxxyApi.dismissNotification", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val idArg = args[0].let { if (it is Int) it.toLong() else it as Long }
var wrapped: List<Any?>
try {
api.dismissNotification(idArg)
wrapped = listOf<Any?>(null)
} catch (exception: Throwable) {
wrapped = wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxyv2.MoxxyApi.setNotificationSelfAvatar", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val pathArg = args[0] as String
var wrapped: List<Any?>
try {
api.setNotificationSelfAvatar(pathArg)
wrapped = listOf<Any?>(null)
} catch (exception: Throwable) {
wrapped = wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxyv2.MoxxyApi.setNotificationI18n", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val dataArg = args[0] as NotificationI18nData
var wrapped: List<Any?>
try {
api.setNotificationI18n(dataArg)
wrapped = listOf<Any?>(null)
} catch (exception: Throwable) {
wrapped = wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxyv2.MoxxyApi.notificationStub", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val eventArg = args[0] as NotificationEvent
var wrapped: List<Any?>
try {
api.notificationStub(eventArg)
wrapped = listOf<Any?>(null)
} catch (exception: Throwable) {
wrapped = wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@ -0,0 +1,32 @@
package org.moxxy.moxxyv2
// The tag we use for logging.
const val TAG = "Moxxy"
// The data key for text entered in the notification's reply field
const val REPLY_TEXT_KEY = "key_reply_text"
// The key for the notification id to mark as read
const val MARK_AS_READ_ID_KEY = "notification_id"
// Values for actions performed through the notification
const val REPLY_ACTION = "reply"
const val MARK_AS_READ_ACTION = "mark_as_read"
const val TAP_ACTION = "tap"
// Extra data keys for the intents that reach the NotificationReceiver
const val NOTIFICATION_EXTRA_JID_KEY = "jid"
const val NOTIFICATION_EXTRA_ID_KEY = "notification_id"
// Extra data keys for messages embedded inside the notification style
const val NOTIFICATION_MESSAGE_EXTRA_MIME = "mime"
const val NOTIFICATION_MESSAGE_EXTRA_PATH = "path"
const val MOXXY_FILEPROVIDER_ID = "org.moxxy.moxxyv2.fileprovider"
// Shared preferences keys
const val SHARED_PREFERENCES_KEY = "org.moxxy.moxxyv2"
const val SHARED_PREFERENCES_YOU_KEY = "you"
const val SHARED_PREFERENCES_MARK_AS_READ_KEY = "mark_as_read"
const val SHARED_PREFERENCES_REPLY_KEY = "reply"
const val SHARED_PREFERENCES_AVATAR_KEY = "avatar_path"

View File

@ -1,6 +1,140 @@
package org.moxxy.moxxyv2
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import org.moxxy.moxxyv2.notifications.NotificationDataManager
import org.moxxy.moxxyv2.notifications.createNotificationChannelsImpl
import org.moxxy.moxxyv2.notifications.createNotificationGroupsImpl
import org.moxxy.moxxyv2.notifications.extractPayloadMapFromIntent
import org.moxxy.moxxyv2.notifications.showNotificationImpl
class MainActivity: FlutterActivity() {
object MoxxyEventChannels {
var notificationChannel: EventChannel? = null
var notificationEventSink: EventSink? = null
}
object NotificationStreamHandler : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventSink?) {
Log.d(TAG, "NotificationStreamHandler: Attached stream")
MoxxyEventChannels.notificationEventSink = events
}
override fun onCancel(arguments: Any?) {
Log.d(TAG, "NotificationStreamHandler: Detached stream")
MoxxyEventChannels.notificationEventSink = null
}
}
/*
* Hold the last notification event in case we did a cold start.
* TODO: Currently unused, but useful in the future.
* */
object NotificationCache {
var lastEvent: NotificationEvent? = null
}
class MainActivity : FlutterActivity(), FlutterPlugin, MoxxyApi {
private var context: Context? = null
private fun handleIntent(intent: Intent?) {
if (intent == null) return
when (intent.action) {
TAP_ACTION -> {
Log.d(TAG, "Handling tap data")
val event = NotificationEvent(
intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1),
intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!,
NotificationEventType.OPEN,
null,
extractPayloadMapFromIntent(intent),
)
NotificationCache.lastEvent = event
MoxxyEventChannels.notificationEventSink?.success(
event.toList(),
)
}
else -> Log.d(TAG, "Unknown intent action: ${intent.action}")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
override fun createNotificationGroups(groups: List<NotificationGroup>) {
createNotificationGroupsImpl(context!!, groups)
}
override fun deleteNotificationGroups(ids: List<String>) {
val notificationManager = context!!.getSystemService(NotificationManager::class.java)
for (id in ids) {
notificationManager.deleteNotificationChannelGroup(id)
}
}
override fun createNotificationChannels(channels: List<NotificationChannel>) {
createNotificationChannelsImpl(context!!, channels)
}
override fun deleteNotificationChannels(ids: List<String>) {
val notificationManager = context!!.getSystemService(NotificationManager::class.java)
for (id in ids) {
notificationManager.deleteNotificationChannel(id)
}
}
override fun showMessagingNotification(notification: MessagingNotification) {
org.moxxy.moxxyv2.notifications.showMessagingNotification(context!!, notification)
}
override fun showNotification(notification: RegularNotification) {
showNotificationImpl(context!!, notification)
}
override fun dismissNotification(id: Long) {
NotificationManagerCompat.from(context!!).cancel(id.toInt())
}
override fun setNotificationSelfAvatar(path: String) {
NotificationDataManager.setAvatarPath(context!!, path)
}
override fun setNotificationI18n(data: NotificationI18nData) {
NotificationDataManager.apply {
setYou(context!!, data.you)
setReply(context!!, data.reply)
setMarkAsRead(context!!, data.markAsRead)
}
}
override fun notificationStub(event: NotificationEvent) {}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
MoxxyEventChannels.notificationChannel = EventChannel(binding.binaryMessenger, "org.moxxy.moxxyv2/notification_stream")
MoxxyEventChannels.notificationChannel!!.setStreamHandler(NotificationStreamHandler)
MoxxyApi.setUp(binding.binaryMessenger, this)
Log.d(TAG, "Attached to engine")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
Log.d(TAG, "Detached from engine")
}
}

View File

@ -0,0 +1,6 @@
package org.moxxy.moxxyv2.notifications
import androidx.core.content.FileProvider
import org.moxxy.moxxyv2.R
class MoxxyFileProvider : FileProvider(R.xml.file_paths)

View File

@ -0,0 +1,370 @@
package org.moxxy.moxxyv2.notifications
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.Color
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.app.TaskStackBuilder
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.IconCompat
import org.moxxy.moxxyv2.MARK_AS_READ_ACTION
import org.moxxy.moxxyv2.MOXXY_FILEPROVIDER_ID
import org.moxxy.moxxyv2.MainActivity
import org.moxxy.moxxyv2.MessagingNotification
import org.moxxy.moxxyv2.NOTIFICATION_EXTRA_ID_KEY
import org.moxxy.moxxyv2.NOTIFICATION_EXTRA_JID_KEY
import org.moxxy.moxxyv2.NOTIFICATION_MESSAGE_EXTRA_MIME
import org.moxxy.moxxyv2.NOTIFICATION_MESSAGE_EXTRA_PATH
import org.moxxy.moxxyv2.NotificationChannel
import org.moxxy.moxxyv2.NotificationChannelImportance
import org.moxxy.moxxyv2.NotificationGroup
import org.moxxy.moxxyv2.NotificationIcon
import org.moxxy.moxxyv2.R
import org.moxxy.moxxyv2.REPLY_ACTION
import org.moxxy.moxxyv2.REPLY_TEXT_KEY
import org.moxxy.moxxyv2.RegularNotification
import org.moxxy.moxxyv2.SHARED_PREFERENCES_AVATAR_KEY
import org.moxxy.moxxyv2.SHARED_PREFERENCES_KEY
import org.moxxy.moxxyv2.SHARED_PREFERENCES_MARK_AS_READ_KEY
import org.moxxy.moxxyv2.SHARED_PREFERENCES_REPLY_KEY
import org.moxxy.moxxyv2.SHARED_PREFERENCES_YOU_KEY
import org.moxxy.moxxyv2.TAG
import org.moxxy.moxxyv2.TAP_ACTION
import java.io.File
/*
* Holds "persistent" data for notifications, like i18n strings. While not useful now, this is
* useful for when the app is dead and we receive a notification.
* */
object NotificationDataManager {
private var you: String? = null
private var markAsRead: String? = null
private var reply: String? = null
private var fetchedAvatarPath = false
private var avatarPath: String? = null
private fun getString(context: Context, key: String, fallback: String): String {
return context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)!!.getString(key, fallback)!!
}
private fun setString(context: Context, key: String, value: String) {
val prefs = context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
prefs.edit()
.putString(key, value)
.apply()
}
fun getYou(context: Context): String {
if (you == null) you = getString(context, SHARED_PREFERENCES_YOU_KEY, "You")
return you!!
}
fun setYou(context: Context, value: String) {
setString(context, SHARED_PREFERENCES_YOU_KEY, value)
you = value
}
fun getMarkAsRead(context: Context): String {
if (markAsRead == null) markAsRead = getString(context, SHARED_PREFERENCES_MARK_AS_READ_KEY, "Mark as read")
return markAsRead!!
}
fun setMarkAsRead(context: Context, value: String) {
setString(context, SHARED_PREFERENCES_MARK_AS_READ_KEY, value)
markAsRead = value
}
fun getReply(context: Context): String {
if (reply != null) reply = getString(context, SHARED_PREFERENCES_REPLY_KEY, "Reply")
return reply!!
}
fun setReply(context: Context, value: String) {
setString(context, SHARED_PREFERENCES_REPLY_KEY, value)
reply = value
}
fun getAvatarPath(context: Context): String? {
if (avatarPath == null && !fetchedAvatarPath) {
val path = getString(context, SHARED_PREFERENCES_AVATAR_KEY, "")
if (path.isNotEmpty()) {
avatarPath = path
}
}
return avatarPath
}
fun setAvatarPath(context: Context, value: String) {
setString(context, SHARED_PREFERENCES_AVATAR_KEY, value)
fetchedAvatarPath = true
avatarPath = value
}
}
fun createNotificationGroupsImpl(context: Context, groups: List<NotificationGroup>) {
val notificationManager = context.getSystemService(NotificationManager::class.java)
for (group in groups) {
notificationManager.createNotificationChannelGroup(
NotificationChannelGroup(group.id, group.description),
)
}
}
fun createNotificationChannelsImpl(context: Context, channels: List<NotificationChannel>) {
val notificationManager = context.getSystemService(NotificationManager::class.java)
for (channel in channels) {
val importance = when (channel.importance) {
NotificationChannelImportance.DEFAULT -> NotificationManager.IMPORTANCE_DEFAULT
NotificationChannelImportance.MIN -> NotificationManager.IMPORTANCE_MIN
NotificationChannelImportance.HIGH -> NotificationManager.IMPORTANCE_HIGH
}
val notificationChannel = android.app.NotificationChannel(channel.id, channel.title, importance).apply {
description = channel.description
enableVibration(channel.vibration)
enableLights(channel.enableLights)
setShowBadge(channel.showBadge)
if (channel.groupId != null) {
group = channel.groupId
}
}
notificationManager.createNotificationChannel(notificationChannel)
}
}
// / Show a messaging style notification described by @notification.
@SuppressLint("WrongConstant")
fun showMessagingNotification(context: Context, notification: MessagingNotification) {
// Build the actions
// -> Reply action
val remoteInput = RemoteInput.Builder(REPLY_TEXT_KEY).apply {
setLabel(NotificationDataManager.getReply(context))
}.build()
val replyIntent = Intent(context, NotificationReceiver::class.java).apply {
action = REPLY_ACTION
putExtra(NOTIFICATION_EXTRA_JID_KEY, notification.jid)
putExtra(NOTIFICATION_EXTRA_ID_KEY, notification.id)
notification.extra?.forEach {
putExtra("payload_${it.key}", it.value)
}
}
val replyPendingIntent = PendingIntent.getBroadcast(
context.applicationContext,
0,
replyIntent,
PendingIntent.FLAG_MUTABLE,
)
val replyAction = NotificationCompat.Action.Builder(
R.drawable.reply,
NotificationDataManager.getReply(context),
replyPendingIntent,
).apply {
addRemoteInput(remoteInput)
setAllowGeneratedReplies(true)
}.build()
// -> Mark as read action
val markAsReadIntent = Intent(context, NotificationReceiver::class.java).apply {
action = MARK_AS_READ_ACTION
putExtra(NOTIFICATION_EXTRA_JID_KEY, notification.jid)
putExtra(NOTIFICATION_EXTRA_ID_KEY, notification.id)
notification.extra?.forEach {
putExtra("payload_${it.key}", it.value)
}
}
val markAsReadPendingIntent = PendingIntent.getBroadcast(
context.applicationContext,
0,
markAsReadIntent,
PendingIntent.FLAG_IMMUTABLE,
)
val markAsReadAction = NotificationCompat.Action.Builder(
R.drawable.mark_as_read,
NotificationDataManager.getMarkAsRead(context),
markAsReadPendingIntent,
).build()
// -> Tap action
// NOTE: Because Android disallows "notification trampolines" (https://developer.android.com/about/versions/12/behavior-changes-12#notification-trampolines),
// we must do it this way instead of just using startActivity
val tapIntent = Intent(context, MainActivity::class.java).apply {
action = TAP_ACTION
putExtra(NOTIFICATION_EXTRA_JID_KEY, notification.jid)
putExtra(NOTIFICATION_EXTRA_ID_KEY, notification.id)
notification.extra?.forEach {
putExtra("payload_${it.key}", it.value)
}
// Do not launch a new task
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val tapPendingIntent = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(tapIntent)
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
// Build the notification
val selfPerson = Person.Builder().apply {
setName(NotificationDataManager.getYou(context))
// Set an avatar, if we have one
val avatarPath = NotificationDataManager.getAvatarPath(context)
if (avatarPath != null) {
setIcon(
IconCompat.createWithAdaptiveBitmap(
BitmapFactory.decodeFile(avatarPath),
),
)
}
}.build()
val style = NotificationCompat.MessagingStyle(selfPerson)
style.isGroupConversation = notification.isGroupchat
if (notification.isGroupchat) {
style.conversationTitle = notification.title
}
for (i in notification.messages.indices) {
val message = notification.messages[i]!!
// Build the sender
// NOTE: Note that we set it to null if message.sender == null because otherwise this results in
// a bogus Person object which messes with the "self-message" display as Android expects
// null in that case.
val sender = if (message.sender == null) {
null
} else {
Person.Builder().apply {
setName(message.sender)
setKey(message.jid)
// Set the avatar, if available
if (message.avatarPath != null) {
try {
setIcon(
IconCompat.createWithAdaptiveBitmap(
BitmapFactory.decodeFile(message.avatarPath),
),
)
} catch (ex: Throwable) {
Log.w(TAG, "Failed to open avatar at ${message.avatarPath}")
}
}
}.build()
}
// Build the message
val body = message.content.body ?: ""
val msg = NotificationCompat.MessagingStyle.Message(
body,
message.timestamp,
sender,
)
// If we got an image, turn it into a content URI and set it
if (message.content.mime != null && message.content.path != null) {
val fileUri = FileProvider.getUriForFile(
context,
MOXXY_FILEPROVIDER_ID,
File(message.content.path),
)
msg.apply {
setData(message.content.mime, fileUri)
extras.apply {
putString(NOTIFICATION_MESSAGE_EXTRA_MIME, message.content.mime)
putString(NOTIFICATION_MESSAGE_EXTRA_PATH, message.content.path)
}
}
}
// Append the message
style.addMessage(msg)
}
// Assemble the notification
val finalNotification = NotificationCompat.Builder(context, notification.channelId).apply {
setStyle(style)
// NOTE: It's okay to use the service icon here as I cannot get Android to display the
// actual logo. So we'll have to make do with the silhouette and the color purple.
setSmallIcon(R.drawable.ic_service_icon)
color = Color.argb(255, 207, 74, 255)
setColorized(true)
// Tap action
setContentIntent(tapPendingIntent)
// Notification actions
addAction(replyAction)
addAction(markAsReadAction)
// Groupchat title
if (notification.isGroupchat) {
setContentTitle(notification.title)
}
// Prevent grouping with the foreground service
if (notification.groupId != null) {
setGroup(notification.groupId)
}
setAllowSystemGeneratedContextualActions(true)
setCategory(Notification.CATEGORY_MESSAGE)
// Prevent no notification when we replied before
setOnlyAlertOnce(false)
// Automatically dismiss the notification on tap
setAutoCancel(true)
}.build()
// Post the notification
try {
NotificationManagerCompat.from(context).notify(
notification.id.toInt(),
finalNotification,
)
} catch (ex: SecurityException) {
// Should never happen as Moxxy checks for the permission before posting the notification
Log.e(TAG, "Failed to post notification: ${ex.message}")
}
}
fun showNotificationImpl(context: Context, notification: RegularNotification) {
val builtNotification = NotificationCompat.Builder(context, notification.channelId).apply {
setContentTitle(notification.title)
setContentText(notification.body)
when (notification.icon) {
NotificationIcon.ERROR -> setSmallIcon(R.drawable.error)
NotificationIcon.WARNING -> setSmallIcon(R.drawable.warning)
NotificationIcon.NONE -> {}
}
if (notification.groupId != null) {
setGroup(notification.groupId)
}
}.build()
// Post the notification
try {
NotificationManagerCompat.from(context).notify(notification.id.toInt(), builtNotification)
} catch (ex: SecurityException) {
// Should never happen as Moxxy checks for the permission before posting the notification
Log.e(TAG, "Failed to post notification: ${ex.message}")
}
}

View File

@ -0,0 +1,214 @@
package org.moxxy.moxxyv2.notifications
import android.app.Notification
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import androidx.core.content.FileProvider
import org.moxxy.moxxyv2.MARK_AS_READ_ACTION
import org.moxxy.moxxyv2.MOXXY_FILEPROVIDER_ID
import org.moxxy.moxxyv2.MoxxyEventChannels
import org.moxxy.moxxyv2.NOTIFICATION_EXTRA_ID_KEY
import org.moxxy.moxxyv2.NOTIFICATION_EXTRA_JID_KEY
import org.moxxy.moxxyv2.NOTIFICATION_MESSAGE_EXTRA_MIME
import org.moxxy.moxxyv2.NOTIFICATION_MESSAGE_EXTRA_PATH
import org.moxxy.moxxyv2.NotificationEvent
import org.moxxy.moxxyv2.NotificationEventType
import org.moxxy.moxxyv2.REPLY_ACTION
import org.moxxy.moxxyv2.REPLY_TEXT_KEY
import org.moxxy.moxxyv2.TAG
import org.moxxy.moxxyv2.TAP_ACTION
import java.io.File
import java.time.Instant
fun extractPayloadMapFromIntent(intent: Intent): Map<String?, String?> {
val extras = mutableMapOf<String?, String?>()
intent.extras?.keySet()!!.forEach {
Log.d(TAG, "Checking $it -> ${intent.extras!!.get(it)}")
if (it.startsWith("payload_")) {
Log.d(TAG, "Adding $it")
extras[it.substring(8)] = intent.extras!!.getString(it)
}
}
return extras
}
class NotificationReceiver : BroadcastReceiver() {
/*
* Dismisses the notification through which we received @intent.
* */
private fun dismissNotification(context: Context, intent: Intent) {
// Dismiss the notification
val notificationId = intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1).toInt()
if (notificationId != -1) {
NotificationManagerCompat.from(context).cancel(
notificationId,
)
} else {
Log.e("NotificationReceiver", "No id specified. Cannot dismiss notification")
}
}
private fun findActiveNotification(context: Context, id: Int): Notification? {
return (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.activeNotifications
.find { it.id == id }?.notification
}
private fun handleMarkAsRead(context: Context, intent: Intent) {
MoxxyEventChannels.notificationEventSink?.success(
NotificationEvent(
intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1),
intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!,
NotificationEventType.MARKASREAD,
null,
extractPayloadMapFromIntent(intent),
).toList(),
)
dismissNotification(context, intent)
}
private fun handleReply(context: Context, intent: Intent) {
val remoteInput = RemoteInput.getResultsFromIntent(intent) ?: return
val replyPayload = remoteInput.getCharSequence(REPLY_TEXT_KEY)
MoxxyEventChannels.notificationEventSink?.success(
NotificationEvent(
intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1),
intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!,
NotificationEventType.REPLY,
replyPayload.toString(),
extractPayloadMapFromIntent(intent),
).toList(),
)
val id = intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1).toInt()
if (id == -1) {
Log.e(TAG, "Failed to find notification id for reply")
return
}
val notification = findActiveNotification(context, id)
if (notification == null) {
Log.e(TAG, "Failed to find notification for id $id")
return
}
// Thanks https://medium.com/@sidorovroman3/android-how-to-use-messagingstyle-for-notifications-without-caching-messages-c414ef2b816c
val recoveredStyle = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(notification)!!
val newStyle = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Notification.MessagingStyle(
android.app.Person.Builder().apply {
setName(NotificationDataManager.getYou(context))
// Set an avatar, if we have one
val avatarPath = NotificationDataManager.getAvatarPath(context)
if (avatarPath != null) {
setIcon(
Icon.createWithAdaptiveBitmap(
BitmapFactory.decodeFile(avatarPath),
),
)
}
}.build(),
)
} else {
Notification.MessagingStyle(NotificationDataManager.getYou(context))
}
newStyle.apply {
conversationTitle = recoveredStyle.conversationTitle
recoveredStyle.messages.forEach {
// Check if we have to request (or refresh) the content URI to be able to still
// see the embedded image.
val mime = it.extras.getString(NOTIFICATION_MESSAGE_EXTRA_MIME)
val path = it.extras.getString(NOTIFICATION_MESSAGE_EXTRA_PATH)
val message = Notification.MessagingStyle.Message(it.text, it.timestamp, it.sender)
if (mime != null && path != null) {
// Request a new URI from the file provider to ensure we can still see the image
// in the notification
val fileUri = FileProvider.getUriForFile(
context,
MOXXY_FILEPROVIDER_ID,
File(path),
)
message.setData(
mime,
fileUri,
)
// As we're creating a new message, also recreate the additional metadata
message.extras.apply {
putString(NOTIFICATION_MESSAGE_EXTRA_MIME, mime)
putString(NOTIFICATION_MESSAGE_EXTRA_PATH, path)
}
}
// Append the old message
addMessage(message)
}
}
// Append our new message
newStyle.addMessage(
Notification.MessagingStyle.Message(
replyPayload!!,
Instant.now().toEpochMilli(),
null as CharSequence?,
),
)
// Post the new notification
val recoveredBuilder = Notification.Builder.recoverBuilder(context, notification).apply {
style = newStyle
setOnlyAlertOnce(true)
}
try {
NotificationManagerCompat.from(context).notify(id, recoveredBuilder.build())
} catch (ex: SecurityException) {
Log.e(TAG, "Failed to post reply-notification: ${ex.message}")
}
}
fun handleTap(context: Context, intent: Intent) {
MoxxyEventChannels.notificationEventSink?.success(
NotificationEvent(
intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1),
intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!,
NotificationEventType.OPEN,
null,
extractPayloadMapFromIntent(intent),
).toList(),
)
// Bring the app into the foreground
Log.d(TAG, "Querying launch intent for ${context.packageName}")
val tapIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)!!
Log.d(TAG, "Starting activity")
context.startActivity(tapIntent)
// Dismiss the notification
Log.d(TAG, "Dismissing notification")
dismissNotification(context, intent)
}
override fun onReceive(context: Context, intent: Intent) {
// TODO: We need to be careful to ensure that the Flutter engine is running.
// If it's not, we have to start it. However, that's only an issue when we expect to
// receive notifications while not running, i.e. Push Notifications.
when (intent.action) {
MARK_AS_READ_ACTION -> handleMarkAsRead(context, intent)
REPLY_ACTION -> handleReply(context, intent)
TAP_ACTION -> handleTap(context, intent)
}
}
}

View File

@ -0,0 +1,7 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Media files -->
<files-path name="media" path="media/" />
<!-- Media thumbnails -->
<cache-path name="thumbnails" path="thumbnails/" />
</paths>

View File

@ -24,7 +24,9 @@
"messagesChannelName": "Messages",
"messagesChannelDescription": "The notification channel for received messages",
"warningChannelName": "Warnings",
"warningChannelDescription": "Warnings related to Moxxy"
"warningChannelDescription": "Warnings related to Moxxy",
"serviceChannelName": "Foreground Service",
"serviceChannelDescription": "Holds the persistent foreground service notification"
},
"titles": {
"error": "Error"

View File

@ -1,6 +1,12 @@
- (Hopefully) fix OMEMO between two Moxxy clients.
- Allow correcting messages older than the last one. Whether all clients will accept such a correction is unclear.
- Add (incomplete) translations for Dutch, Japanese, and Russian.
- Add (incomplete) translations for Dutch, French, Galician, Japanese, Polish, and Russian.
- Fix having to long-press a message bubble on its corner to active the selection menu.
- If enabled, read markers are automatically sent.
- Highlight legacy quotes in text messages.
- Highlight legacy quotes in text messages.
- Fix Moxxy's app icon having a badge because of the foreground service.
- Make the notifications much prettier and compliant with Android 13.
- Prevent Moxxy from crashing on startup on a fresh device.
- Video thumbnails are now generated, if possible, after a video has been downloaded.
- The Moxxy APKs will now be signed by a different key stored on my YubiKey. You will have to uninstall and reinstall Moxxy. This will remove all your data.
- Moxxy now uses Android's direct share shortcuts.

View File

@ -55,7 +55,7 @@
devShell = pkgs.mkShell {
buildInputs = with pkgs; [
# Android
pinnedJDK sdk
pinnedJDK sdk ktlint
scrcpy
# Flutter

View File

@ -481,6 +481,12 @@ files:
- JsonImplementation
attributes:
jid: String
- name: ExitConversationCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
conversationType: String
- name: SendChatStateCommand
extends: BackgroundCommand
implements:

View File

@ -89,7 +89,7 @@ class AvatarService {
final rs = GetIt.I.get<RosterService>();
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final originalConversation =
await cs.getConversationByJid(jid.toString(), accountJid);
await cs.getConversationByJid(jid.toString(), accountJid!);
final originalRoster = await rs.getRosterItemByJid(
jid.toString(),
accountJid,
@ -197,8 +197,9 @@ class AvatarService {
/// Like [requestAvatar], but fetches and processes the avatar for our own account.
Future<void> requestOwnAvatar() async {
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.getXmppState();
final jid = JID.fromString(state.jid!);
final accountJid = await xss.getAccountJid();
final state = await xss.state;
final jid = JID.fromString(accountJid!);
if (_requestedInStream.contains(jid)) {
return;

View File

@ -72,7 +72,7 @@ class BlocklistService {
final removedItems = List<String>.empty(growable: true);
for (final item in blocklist) {
if (!_blocklist!.contains(item)) {
await _addBlocklistEntry(item, accountJid);
await _addBlocklistEntry(item, accountJid!);
_blocklist!.add(item);
newItems.add(item);
}
@ -81,7 +81,7 @@ class BlocklistService {
// Diff the cache with the received blocklist
for (final item in _blocklist!) {
if (!blocklist.contains(item)) {
await _removeBlocklistEntry(item, accountJid);
await _removeBlocklistEntry(item, accountJid!);
_blocklist!.remove(item);
removedItems.add(item);
}
@ -146,7 +146,7 @@ class BlocklistService {
_blocklist!.add(item);
newBlocks.add(item);
await _addBlocklistEntry(item, accountJid);
await _addBlocklistEntry(item, accountJid!);
}
break;
case BlockPushType.unblock:
@ -154,7 +154,7 @@ class BlocklistService {
_blocklist!.removeWhere((i) => i == item);
removedBlocks.add(item);
await _removeBlocklistEntry(item, accountJid);
await _removeBlocklistEntry(item, accountJid!);
}
break;
}
@ -178,7 +178,7 @@ class BlocklistService {
_blocklist!.add(jid);
await _addBlocklistEntry(
jid,
await GetIt.I.get<XmppStateService>().getAccountJid(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
return GetIt.I
.get<XmppConnection>()
@ -196,7 +196,7 @@ class BlocklistService {
_blocklist!.remove(jid);
await _removeBlocklistEntry(
jid,
await GetIt.I.get<XmppStateService>().getAccountJid(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
return GetIt.I
.get<XmppConnection>()

View File

@ -61,7 +61,7 @@ class ContactsService {
FlutterContacts.removeListener(_onContactsDatabaseUpdate);
await GetIt.I.get<RosterService>().removePseudoRosterItems(
await GetIt.I.get<XmppStateService>().getAccountJid(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
}
@ -218,7 +218,7 @@ class ContactsService {
// Remove the contact attributes from the conversation, if it existed
final conversation = await cs.createOrUpdateConversation(
jid,
accountJid,
accountJid!,
update: (c) async {
return cs.updateConversation(
jid,
@ -284,7 +284,7 @@ class ContactsService {
// Update a possibly existing conversation
final conversation = await cs.createOrUpdateConversation(
contact.jid,
accountJid,
accountJid!,
update: (c) async {
return cs.updateConversation(
contact.jid,

View File

@ -1,5 +1,7 @@
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
@ -28,6 +30,17 @@ class ConversationService {
/// The lock for accessing _conversationCache
final Lock _lock = Lock();
final Logger _log = Logger('ConversationService');
String? _activeConversationJid;
String? get activeConversationJid => _activeConversationJid;
set activeConversationJid(String? jid) {
_log.finest('Setting activeConversationJid to $jid');
_activeConversationJid = jid;
}
/// When called with a JID [jid], then first, if non-null, [preRun] is
/// executed.
/// Next, if a conversation with JID [jid] exists, [update] is called with
@ -309,4 +322,36 @@ class ConversationService {
final conversation = await getConversationByJid(jid.toString(), accountJid);
return conversation?.encrypted ?? prefs.enableOmemoByDefault;
}
/// Send a chat state [state] to [jid], if certain pre-conditions are met:
/// - We have a network connection
/// - Sending chat markers/states are enabled
/// - [jid] != '' (not the self-chat)
/// [type] is the type of chat the chat state should be sent within.
Future<void> sendChatState(
ConversationType type,
String jid,
ChatState state,
) async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
// Only send chat states if the users wants to send them
if (!prefs.sendChatMarkers) return;
// Only send chat states when we're connected
// TODO(Unknown): Maybe queue it up intelligently
if (!(await GetIt.I.get<ConnectivityService>().hasConnection())) return;
final conn = GetIt.I.get<XmppConnection>();
if (jid != '') {
await conn
.getManagerById<ChatStateManager>(chatStateManager)!
.sendChatState(
state,
jid,
messageType: type.toMessageType(),
);
}
}
}

View File

@ -57,53 +57,53 @@ import 'package:sqflite_common/src/sql_builder.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
@internal
const List<DatabaseMigration<Database>> migrations = [
DatabaseMigration(2, upgradeFromV1ToV2),
DatabaseMigration(3, upgradeFromV2ToV3),
DatabaseMigration(4, upgradeFromV3ToV4),
DatabaseMigration(5, upgradeFromV4ToV5),
DatabaseMigration(6, upgradeFromV5ToV6),
DatabaseMigration(7, upgradeFromV6ToV7),
DatabaseMigration(8, upgradeFromV7ToV8),
DatabaseMigration(9, upgradeFromV8ToV9),
DatabaseMigration(10, upgradeFromV9ToV10),
DatabaseMigration(11, upgradeFromV10ToV11),
DatabaseMigration(12, upgradeFromV11ToV12),
DatabaseMigration(13, upgradeFromV12ToV13),
DatabaseMigration(14, upgradeFromV13ToV14),
DatabaseMigration(15, upgradeFromV14ToV15),
DatabaseMigration(16, upgradeFromV15ToV16),
DatabaseMigration(17, upgradeFromV16ToV17),
DatabaseMigration(18, upgradeFromV17ToV18),
DatabaseMigration(19, upgradeFromV18ToV19),
DatabaseMigration(20, upgradeFromV19ToV20),
DatabaseMigration(21, upgradeFromV20ToV21),
DatabaseMigration(22, upgradeFromV21ToV22),
DatabaseMigration(23, upgradeFromV22ToV23),
DatabaseMigration(24, upgradeFromV23ToV24),
DatabaseMigration(25, upgradeFromV24ToV25),
DatabaseMigration(26, upgradeFromV25ToV26),
DatabaseMigration(27, upgradeFromV26ToV27),
DatabaseMigration(28, upgradeFromV27ToV28),
DatabaseMigration(29, upgradeFromV28ToV29),
DatabaseMigration(30, upgradeFromV29ToV30),
DatabaseMigration(31, upgradeFromV30ToV31),
DatabaseMigration(32, upgradeFromV31ToV32),
DatabaseMigration(33, upgradeFromV32ToV33),
DatabaseMigration(34, upgradeFromV33ToV34),
DatabaseMigration(35, upgradeFromV34ToV35),
DatabaseMigration(36, upgradeFromV35ToV36),
DatabaseMigration(37, upgradeFromV36ToV37),
DatabaseMigration(38, upgradeFromV37ToV38),
DatabaseMigration(39, upgradeFromV38ToV39),
DatabaseMigration(40, upgradeFromV39ToV40),
DatabaseMigration(41, upgradeFromV40ToV41),
DatabaseMigration(42, upgradeFromV41ToV42),
DatabaseMigration(43, upgradeFromV42ToV43),
DatabaseMigration(44, upgradeFromV43ToV44),
DatabaseMigration(45, upgradeFromV44ToV45),
DatabaseMigration(46, upgradeFromV45ToV46),
DatabaseMigration(47, upgradeFromV46ToV47),
const List<Migration<Database>> migrations = [
Migration(2, upgradeFromV1ToV2),
Migration(3, upgradeFromV2ToV3),
Migration(4, upgradeFromV3ToV4),
Migration(5, upgradeFromV4ToV5),
Migration(6, upgradeFromV5ToV6),
Migration(7, upgradeFromV6ToV7),
Migration(8, upgradeFromV7ToV8),
Migration(9, upgradeFromV8ToV9),
Migration(10, upgradeFromV9ToV10),
Migration(11, upgradeFromV10ToV11),
Migration(12, upgradeFromV11ToV12),
Migration(13, upgradeFromV12ToV13),
Migration(14, upgradeFromV13ToV14),
Migration(15, upgradeFromV14ToV15),
Migration(16, upgradeFromV15ToV16),
Migration(17, upgradeFromV16ToV17),
Migration(18, upgradeFromV17ToV18),
Migration(19, upgradeFromV18ToV19),
Migration(20, upgradeFromV19ToV20),
Migration(21, upgradeFromV20ToV21),
Migration(22, upgradeFromV21ToV22),
Migration(23, upgradeFromV22ToV23),
Migration(24, upgradeFromV23ToV24),
Migration(25, upgradeFromV24ToV25),
Migration(26, upgradeFromV25ToV26),
Migration(27, upgradeFromV26ToV27),
Migration(28, upgradeFromV27ToV28),
Migration(29, upgradeFromV28ToV29),
Migration(30, upgradeFromV29ToV30),
Migration(31, upgradeFromV30ToV31),
Migration(32, upgradeFromV31ToV32),
Migration(33, upgradeFromV32ToV33),
Migration(34, upgradeFromV33ToV34),
Migration(35, upgradeFromV34ToV35),
Migration(36, upgradeFromV35ToV36),
Migration(37, upgradeFromV36ToV37),
Migration(38, upgradeFromV37ToV38),
Migration(39, upgradeFromV38ToV39),
Migration(40, upgradeFromV39ToV40),
Migration(41, upgradeFromV40ToV41),
Migration(42, upgradeFromV41ToV42),
Migration(43, upgradeFromV42ToV43),
Migration(44, upgradeFromV43ToV44),
Migration(45, upgradeFromV44ToV45),
Migration(46, upgradeFromV45ToV46),
Migration(47, upgradeFromV46ToV47),
];
class DatabaseService {
@ -150,7 +150,7 @@ class DatabaseService {
await db.execute('PRAGMA foreign_keys = ON');
},
onUpgrade: (db, oldVersion, newVersion) async {
await runMigrations(_log, db, migrations, oldVersion);
await runMigrations(_log, db, migrations, oldVersion, 'database');
},
);

View File

@ -1,20 +1,21 @@
import 'package:logging/logging.dart';
/// A function to be called when a migration should be performed.
typedef DatabaseMigrationCallback<T> = Future<void> Function(T);
typedef MigrationCallback<T> = Future<void> Function(T);
/// This class represents a single database migration.
class DatabaseMigration<T> {
const DatabaseMigration(this.version, this.migration);
class Migration<T> {
const Migration(this.version, this.migration);
/// The version this migration upgrades the database to.
final int version;
/// The migration callback. Called the the database version is less than [version].
final DatabaseMigrationCallback<T> migration;
final MigrationCallback<T> migration;
}
/// Given the database [db] with the current version [version], goes through the list of
/// Given the migration [param], which is passed to every migration, with the current version
/// [version], goes through the list of
/// migrations [migrations] and applies all migrations with a version greater than
/// [version]. [migrations] is sorted before usage.
///
@ -23,22 +24,32 @@ class DatabaseMigration<T> {
/// database argument, just pass in whatever (the tests use an integer).
Future<void> runMigrations<T>(
Logger log,
T db,
List<DatabaseMigration<T>> migrations,
T param,
List<Migration<T>> migrations,
int version,
) async {
final sortedMigrations = List<DatabaseMigration<T>>.from(migrations)
String typeName, {
Future<void> Function(int)? commitVersion,
}) async {
final sortedMigrations = List<Migration<T>>.from(migrations)
..sort(
(a, b) => a.version.compareTo(b.version),
);
var currentVersion = version;
var hasRunMigration = false;
for (final migration in sortedMigrations) {
if (version < migration.version) {
log.info(
'Running database migration $currentVersion -> ${migration.version}',
'Running $typeName migration $currentVersion -> ${migration.version}',
);
await migration.migration(db);
await migration.migration(param);
currentVersion = migration.version;
hasRunMigration = true;
}
}
// Commit the version, if specified.
if (commitVersion != null && hasRunMigration) {
log.info('Committing migration version $currentVersion');
await commitVersion(currentVersion);
}
}

View File

@ -377,7 +377,7 @@ Future<void> upgradeFromV45ToV46(Database db) async {
'${omemoTrustTable}_new',
{
...trustItem,
'accoutJid': accountJid,
'accountJid': accountJid,
},
);
}

View File

@ -8,7 +8,6 @@ import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/constants.dart';
@ -20,6 +19,7 @@ import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/language.dart';
import 'package:moxxyv2/service/lifecycle.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
@ -117,6 +117,7 @@ void setupBackgroundEventHandler() {
EventTypeMatcher<FetchRecipientInformationCommand>(
performFetchRecipientInformation,
),
EventTypeMatcher<ExitConversationCommand>(performConversationExited),
]);
GetIt.I.registerSingleton<EventHandler>(handler);
@ -128,6 +129,17 @@ void setupBackgroundEventHandler() {
Future<void> performLogin(LoginCommand command, {dynamic extra}) async {
final id = extra as String;
// Set up the XMPP state
final xss = GetIt.I.get<XmppStateService>();
await xss.setAccountJid(command.jid, commit: false);
await xss.modifyXmppState(
(state) => state.copyWith(
jid: command.jid,
password: command.password,
),
commit: false,
);
GetIt.I.get<Logger>().fine('Performing login...');
final result = await GetIt.I.get<XmppService>().connectAwaitable(
ConnectionSettings(
@ -141,14 +153,25 @@ Future<void> performLogin(LoginCommand command, {dynamic extra}) async {
// ignore: avoid_dynamic_calls
final xc = GetIt.I.get<XmppConnection>();
if (result.isType<bool>() && result.get<bool>()) {
await GetIt.I.get<XmppStateService>().setAccountJid(command.jid);
// Persistently store the JID
await xss.setAccountJid(command.jid);
// Commit the XMPP state
await xss.commitXmppState(command.jid);
final preferences =
await GetIt.I.get<PreferencesService>().getPreferences();
final settings = xc.connectionSettings;
final state = await xss.state;
sendEvent(
LoginSuccessfulEvent(
jid: settings.jid.toString(),
preStart: await _buildPreStartDoneEvent(preferences),
preStart: await _buildPreStartDoneEvent(
state,
command.jid,
preferences,
),
),
id: id,
);
@ -165,12 +188,10 @@ Future<void> performLogin(LoginCommand command, {dynamic extra}) async {
}
Future<PreStartDoneEvent> _buildPreStartDoneEvent(
XmppState state,
String accountJid,
PreferencesState preferences,
) async {
final xss = GetIt.I.get<XmppStateService>();
final accountJid = await xss.getAccountJid();
final state = await xss.getXmppState();
await GetIt.I.get<RosterService>().loadRosterFromDatabase(accountJid);
return PreStartDoneEvent(
@ -203,11 +224,16 @@ Future<void> performPreStart(
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
// Set the locale very early
final logger = GetIt.I.get<Logger>();
GetIt.I.get<LanguageService>().defaultLocale = command.systemLocaleCode;
if (preferences.languageLocaleCode == 'default') {
LocaleSettings.setLocaleRaw(command.systemLocaleCode);
logger.finest('Setting locale to default (${command.systemLocaleCode})');
} else {
LocaleSettings.setLocaleRaw(preferences.languageLocaleCode);
logger.finest(
'Setting locale to configured language (${preferences.languageLocaleCode})',
);
}
await GetIt.I.get<NotificationsService>().configureNotificationI18n();
GetIt.I.get<XmppService>().setNotificationText(
@ -215,25 +241,23 @@ Future<void> performPreStart(
);
// Check if we have an account JID and, if we do, if we have login data.
final accountJid = await GetIt.I.get<XmppStateService>().getRawAccountJid();
final isLoggedIn = accountJid != null
? await GetIt.I.get<XmppService>().getConnectionSettings() != null
: false;
if (isLoggedIn) {
final xss = GetIt.I.get<XmppStateService>();
final accountJid = await xss.getAccountJid();
if (await xss.isLoggedIn(accountJid)) {
sendEvent(
await _buildPreStartDoneEvent(preferences),
await _buildPreStartDoneEvent(
await xss.state,
accountJid!,
preferences,
),
id: id,
);
} else {
sendEvent(
PreStartDoneEvent(
state: 'not_logged_in',
requestNotificationPermission: await GetIt.I
.get<PermissionsService>()
.shouldRequestNotificationPermission(),
excludeFromBatteryOptimisation: await GetIt.I
.get<PermissionsService>()
.shouldRequestBatteryOptimisationExcemption(),
requestNotificationPermission: false,
excludeFromBatteryOptimisation: false,
preferences: preferences,
),
id: id,
@ -253,7 +277,7 @@ Future<void> performAddConversation(
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
await cs.createOrUpdateConversation(
command.jid,
accountJid,
accountJid!,
create: () async {
// Create
final contactId = await css.getContactIdForJid(command.jid);
@ -323,7 +347,7 @@ Future<void> performSetOpenConversation(
if (command.jid != null && command.jid != '') {
await GetIt.I.get<NotificationsService>().dismissNotificationsByJid(
command.jid!,
await GetIt.I.get<XmppStateService>().getAccountJid(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
}
}
@ -343,7 +367,7 @@ Future<void> performSendMessage(
await xs.sendMessageCorrection(
command.editSid!,
command.recipients.first,
accountJid,
accountJid!,
command.body,
command.editSid!,
command.recipients.first,
@ -355,7 +379,7 @@ Future<void> performSendMessage(
}
await xs.sendMessage(
accountJid: accountJid,
accountJid: accountJid!,
body: command.body,
recipients: command.recipients,
chatState: command.chatState.isNotEmpty
@ -390,11 +414,10 @@ Future<void> performSetCSIState(
dynamic extra,
}) async {
// Tell the [XmppService] about the app state
GetIt.I.get<XmppService>().setAppState(command.active);
final conn = GetIt.I.get<XmppConnection>();
GetIt.I.get<LifecycleService>().isActive = command.active;
// Only send the CSI nonza when we're connected
final conn = GetIt.I.get<XmppConnection>();
if (await conn.getConnectionState() != XmppConnectionState.connected) return;
final csi = conn.getManagerById<CSIManager>(csiManager)!;
if (command.active) {
@ -435,11 +458,12 @@ Future<void> performSetPreferences(
// TODO(Unknown): Maybe handle this in StickersService
// If sticker visibility was changed, apply the settings to the PubSub node
final xss = GetIt.I.get<XmppStateService>();
final pm = GetIt.I
.get<XmppConnection>()
.getManagerById<PubSubManager>(pubsubManager)!;
final ownJid = JID.fromString(
(await GetIt.I.get<XmppStateService>().getXmppState()).jid!,
(await xss.state).jid!,
);
if (command.preferences.isStickersNodePublic &&
!oldPrefs.isStickersNodePublic) {
@ -528,7 +552,7 @@ Future<void> performAddContact(
final jid = command.jid;
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final roster = GetIt.I.get<RosterService>();
final inRoster = await roster.isInRoster(jid, accountJid);
final inRoster = await roster.isInRoster(jid, accountJid!);
final cs = GetIt.I.get<ConversationService>();
final conversation = await cs.getConversationByJid(jid, accountJid);
@ -649,7 +673,7 @@ Future<void> performRemoveContact(
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
// Remove from roster
await rs.removeFromRosterWrapper(command.jid, accountJid);
await rs.removeFromRosterWrapper(command.jid, accountJid!);
// Update the conversation
final conversation = await cs.getConversationByJid(command.jid, accountJid);
@ -674,7 +698,7 @@ Future<void> performRequestDownload(
final message = await ms.updateMessage(
command.message.id,
accountJid,
accountJid!,
isDownloading: true,
);
sendEvent(MessageUpdatedEvent(message: message));
@ -735,7 +759,7 @@ Future<void> performSetShareOnlineStatus(
}) async {
final rs = GetIt.I.get<RosterService>();
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final item = await rs.getRosterItemByJid(command.jid, accountJid);
final item = await rs.getRosterItemByJid(command.jid, accountJid!);
// TODO(Unknown): Maybe log
if (item == null) return;
@ -774,7 +798,7 @@ Future<void> performCloseConversation(
await cs.createOrUpdateConversation(
command.jid,
accountJid,
accountJid!,
update: (c) async {
return cs.updateConversation(
command.jid,
@ -794,25 +818,11 @@ Future<void> performSendChatState(
SendChatStateCommand command, {
dynamic extra,
}) async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
// Only send chat states if the users wants to send them
if (!prefs.sendChatMarkers) return;
// Only send chat states when we're connected
if (!(await GetIt.I.get<ConnectivityService>().hasConnection())) return;
final conn = GetIt.I.get<XmppConnection>();
if (command.jid != '') {
await conn
.getManagerById<ChatStateManager>(chatStateManager)!
.sendChatState(
ChatState.fromName(command.state),
command.jid,
messageType: command.conversationType,
);
}
await GetIt.I.get<ConversationService>().sendChatState(
ConversationType.fromString(command.conversationType),
command.jid,
ChatState.fromName(command.state),
);
}
Future<void> performGetFeatures(
@ -854,7 +864,9 @@ Future<void> performSignOut(SignOutCommand command, {dynamic extra}) async {
// Clear notifications
final accountJid = await xss.getAccountJid();
await GetIt.I.get<NotificationsService>().dismissAllNotifications(accountJid);
await GetIt.I
.get<NotificationsService>()
.dismissAllNotifications(accountJid!);
// Reset the current account JID.
await xss.resetAccountJid();
@ -867,7 +879,7 @@ Future<void> performSignOut(SignOutCommand command, {dynamic extra}) async {
Future<void> performSendFiles(SendFilesCommand command, {dynamic extra}) async {
await GetIt.I.get<XmppService>().sendFiles(
await GetIt.I.get<XmppStateService>().getAccountJid(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
command.paths,
command.recipients,
);
@ -882,7 +894,7 @@ Future<void> performSetMuteState(
final conversation = await cs.createOrUpdateConversation(
command.jid,
accountJid,
accountJid!,
update: (c) async {
return cs.updateConversation(
command.jid,
@ -958,7 +970,7 @@ Future<void> performSetOmemoEnabled(
await cs.createOrUpdateConversation(
command.jid,
accountJid,
accountJid!,
update: (c) async {
return cs.updateConversation(
command.jid,
@ -1018,7 +1030,7 @@ Future<void> performMessageRetraction(
}) async {
await GetIt.I.get<MessageService>().retractMessage(
command.conversationJid,
await GetIt.I.get<XmppStateService>().getAccountJid(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
command.originId,
'',
true,
@ -1046,7 +1058,7 @@ Future<void> performMarkConversationAsRead(
// Update the database
final conversation = await cs.createOrUpdateConversation(
command.conversationJid,
accountJid,
accountJid!,
update: (c) async {
return cs.updateConversation(
command.conversationJid,
@ -1070,7 +1082,7 @@ Future<void> performMarkConversationAsRead(
// Dismiss notifications for that chat
await GetIt.I.get<NotificationsService>().dismissNotificationsByJid(
command.conversationJid,
await GetIt.I.get<XmppStateService>().getAccountJid(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
}
@ -1081,7 +1093,7 @@ Future<void> performMarkMessageAsRead(
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
await GetIt.I.get<MessageService>().markMessageAsRead(
command.id,
accountJid,
accountJid!,
command.sendMarker,
);
}
@ -1094,7 +1106,7 @@ Future<void> performAddMessageReaction(
final rs = GetIt.I.get<ReactionsService>();
final msg = await rs.addNewReaction(
command.id,
accountJid,
accountJid!,
accountJid,
command.emoji,
);
@ -1141,7 +1153,7 @@ Future<void> performRemoveMessageReaction(
final rs = GetIt.I.get<ReactionsService>();
final msg = await rs.removeReaction(
command.id,
accountJid,
accountJid!,
accountJid,
command.emoji,
);
@ -1217,7 +1229,7 @@ Future<void> performSendSticker(
dynamic extra,
}) async {
await GetIt.I.get<XmppService>().sendMessage(
accountJid: await GetIt.I.get<XmppStateService>().getAccountJid(),
accountJid: (await GetIt.I.get<XmppStateService>().getAccountJid())!,
body: command.sticker.desc,
recipients: [command.recipient],
sticker: command.sticker,
@ -1332,7 +1344,8 @@ Future<void> performGetBlocklist(
}) async {
final id = extra as String;
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final result = await GetIt.I.get<BlocklistService>().getBlocklist(accountJid);
final result =
await GetIt.I.get<BlocklistService>().getBlocklist(accountJid!);
sendEvent(
GetBlocklistResultEvent(
entries: result,
@ -1349,7 +1362,7 @@ Future<void> performGetPagedMessages(
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final result = await GetIt.I.get<MessageService>().getPaginatedMessagesForJid(
command.conversationJid,
accountJid,
accountJid!,
command.olderThan,
command.timestamp,
);
@ -1370,7 +1383,7 @@ Future<void> performGetPagedSharedMedia(
final result =
await GetIt.I.get<MessageService>().getPaginatedSharedMediaMessagesForJid(
command.conversationJid,
await GetIt.I.get<XmppStateService>().getAccountJid(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
command.olderThan,
command.timestamp,
);
@ -1392,7 +1405,7 @@ Future<void> performGetReactions(
final reactionsRaw =
await GetIt.I.get<ReactionsService>().getReactionsForMessage(
command.id,
accountJid,
accountJid!,
);
final reactionsMap = <String, List<String>>{};
for (final reaction in reactionsRaw) {
@ -1462,7 +1475,7 @@ Future<void> performOldMediaFileDeletion(
newUsage: await GetIt.I.get<StorageService>().computeUsedMediaStorage(),
conversations: (await GetIt.I
.get<ConversationService>()
.loadConversations(accountJid))
.loadConversations(accountJid!))
.where((c) => c.open)
.toList(),
),
@ -1551,7 +1564,7 @@ Future<void> performJoinGroupchat(
final nick = command.nick;
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final cs = GetIt.I.get<ConversationService>();
final conversation = await cs.getConversationByJid(jid, accountJid);
final conversation = await cs.getConversationByJid(jid, accountJid!);
if (conversation != null) {
await cs.createOrUpdateConversation(
jid,
@ -1638,7 +1651,7 @@ Future<void> performFetchRecipientInformation(
final cs = GetIt.I.get<ConversationService>();
for (final jid in command.jids) {
// First try to find a roster item
final rosterItem = await rs.getRosterItemByJid(jid, accountJid);
final rosterItem = await rs.getRosterItemByJid(jid, accountJid!);
if (rosterItem != null) {
items.add(
SendFilesRecipient(
@ -1673,3 +1686,19 @@ Future<void> performFetchRecipientInformation(
id: extra as String,
);
}
Future<void> performConversationExited(
ExitConversationCommand command, {
dynamic extra,
}) async {
// Send a gone marker according to the specified rules
final cs = GetIt.I.get<ConversationService>();
await cs.sendChatState(
ConversationType.fromString(command.conversationType),
cs.activeConversationJid!,
ChatState.gone,
);
// Reset the active conversation
cs.activeConversationJid = null;
}

View File

@ -11,6 +11,7 @@ import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/thumbnails/helpers.dart';
import 'package:path/path.dart' as path;
import 'package:sqflite_common/sql.dart';
@ -323,6 +324,20 @@ class FilesService {
} catch (ex) {
_log.warning('Failed to remove file ${metadata.path!}: $ex');
}
if (metadata.mimeType?.startsWith('video/') ?? false) {
final thumbnailPath = await getVideoThumbnailPath(metadata.path!);
final thumbnailFile = File(thumbnailPath);
if (thumbnailFile.existsSync()) {
try {
await thumbnailFile.delete();
} catch (ex) {
_log.warning(
'Failed to remove thumbnail file $thumbnailPath: $ex',
);
}
}
}
} else {
_log.info('Not removing file as there is no path associated with it');
}

View File

@ -22,6 +22,7 @@ import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/thumbnails/helpers.dart';
import 'package:moxxyv2/shared/warning_types.dart';
import 'package:path/path.dart' as pathlib;
import 'package:path_provider/path_provider.dart';
@ -545,21 +546,33 @@ class HttpFileTransferService {
mediaWidth = imageSize?.width.toInt();
mediaHeight = imageSize?.height.toInt();
} else if (mime.startsWith('video/')) {
/*
// Generate thumbnail
final thumbnailPath = await getVideoThumbnailPath(
downloadedPath,
job.conversationJid,
);
if (canGenerateVideoThumbnail(mime)) {
try {
// Generate thumbnail
final thumbnailPath = await maybeGenerateVideoThumbnail(
downloadedPath,
);
// Find out the dimensions
final imageSize = await getImageSizeFromPath(thumbnailPath);
if (imageSize == null) {
_log.warning('Failed to get image size for $downloadedPath ($thumbnailPath)');
if (thumbnailPath != null) {
// Find out the dimensions
final imageSize = await getImageSizeFromPath(thumbnailPath);
if (imageSize == null) {
_log.warning(
'Failed to get image size for $downloadedPath ($thumbnailPath)',
);
}
mediaWidth = imageSize?.width.toInt();
mediaHeight = imageSize?.height.toInt();
}
} catch (ex) {
_log.warning('Failed to generate thumbnail for $downloadedPath');
}
} else {
_log.info(
'Not generating thumbnail for $downloadedPath because canGenerateVideoThumbnail returned false',
);
}
mediaWidth = imageSize?.width.toInt();
mediaHeight = imageSize?.height.toInt();*/
}
}
@ -616,14 +629,19 @@ class HttpFileTransferService {
cs.setConversation(updatedConversation);
// Show a notification
if (notification.shouldShowNotification(msg.conversationJid) &&
job.shouldShowNotification) {
final shouldShowNotification =
notification.shouldShowNotification(msg.conversationJid);
if (shouldShowNotification && job.shouldShowNotification) {
_log.finest('Creating notification with bigPicture $downloadedPath');
await notification.updateNotification(
await notification.updateOrShowNotification(
updatedConversation,
msg,
job.accountJid,
);
} else {
_log.finest(
'Not creating or updating notification for $downloadedPath: notification.shouldShowNotification=$shouldShowNotification, job.shouldShowNotification=${job.shouldShowNotification}',
);
}
sendEvent(ConversationUpdatedEvent(conversation: updatedConversation));

View File

@ -0,0 +1,16 @@
import 'package:logging/logging.dart';
/// Service which provides other services with information about the state of
/// the app, i.e. if it's in the foreground, minimized, ...
class LifecycleService {
final Logger _log = Logger('LifecycleService');
/// Flag indicating whether the app is currently active, i.e. in the foreground (true),
/// or inactive (false).
bool _active = false;
bool get isActive => _active;
set isActive(bool flag) {
_log.finest('Setting isActive to $flag');
_active = flag;
}
}

View File

@ -37,9 +37,10 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
Future<RosterCacheLoadResult> loadRosterCache() async {
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final rs = GetIt.I.get<RosterService>();
final state = await GetIt.I.get<XmppStateService>().state;
return RosterCacheLoadResult(
(await GetIt.I.get<XmppStateService>().getXmppState()).lastRosterVersion,
(await rs.getRoster(accountJid))
state.lastRosterVersion,
(await rs.getRoster(accountJid!))
.map(
(item) => XmppRosterItem(
jid: item.jid,
@ -71,14 +72,14 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
// Remove stale items
for (final jid in removed) {
await rs.removeRosterItem(jid, accountJid);
await rs.removeRosterItem(jid, accountJid!);
await updateConversation(jid, accountJid, true);
}
// Create new roster items
final rosterAdded = List<RosterItem>.empty(growable: true);
for (final item in added) {
final exists = await rs.getRosterItemByJid(item.jid, accountJid) != null;
final exists = await rs.getRosterItemByJid(item.jid, accountJid!) != null;
// Skip adding items twice
if (exists) continue;
@ -109,7 +110,7 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
// Update modified items
final rosterModified = List<RosterItem>.empty(growable: true);
for (final item in modified) {
final ritem = await rs.getRosterItemByJid(item.jid, accountJid);
final ritem = await rs.getRosterItemByJid(item.jid, accountJid!);
if (ritem == null) {
//_log.warning('Could not find roster item with JID $jid during update');
continue;

View File

@ -30,7 +30,8 @@ class MoxxyStreamManagementManager extends StreamManagementManager {
@override
Future<void> loadState() async {
final state = await GetIt.I.get<XmppStateService>().getXmppState();
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.state;
if (state.smState != null) {
await setState(state.smState!);
}

View File

@ -0,0 +1,75 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/pigeon/api.g.dart';
import 'package:moxxyv2/shared/constants.dart';
/// Recreate all notification channels to apply settings that cannot be applied after the notification
/// channel has been created.
Future<void> upgradeV1ToV2NonDb(int _) async {
// Ensure that we can use the device locale
WidgetsFlutterBinding.ensureInitialized();
LocaleSettings.useDeviceLocale();
final api = MoxxyApi();
// Remove all notification channels, so that we can recreate them
await api.deleteNotificationChannels([
'FOREGROUND_DEFAULT',
'message_channel',
'warning_channel',
// Not sure where this one comes from
'warning',
]);
// Set up notification groups
await api.createNotificationGroups(
[
NotificationGroup(
id: messageNotificationGroupId,
description: 'Chat messages',
),
NotificationGroup(
id: warningNotificationChannelId,
description: 'Warnings',
),
NotificationGroup(
id: foregroundServiceNotificationGroupId,
description: 'Foreground service',
),
],
);
// Set up the notitifcation channels.
await api.createNotificationChannels([
NotificationChannel(
title: t.notifications.channels.messagesChannelName,
description: t.notifications.channels.messagesChannelDescription,
id: messageNotificationChannelId,
importance: NotificationChannelImportance.HIGH,
showBadge: true,
vibration: true,
enableLights: true,
),
NotificationChannel(
title: t.notifications.channels.warningChannelName,
description: t.notifications.channels.warningChannelDescription,
id: warningNotificationChannelId,
importance: NotificationChannelImportance.DEFAULT,
showBadge: false,
vibration: true,
enableLights: false,
),
// The foreground notification channel is only required on Android
if (Platform.isAndroid)
NotificationChannel(
title: t.notifications.channels.serviceChannelName,
description: t.notifications.channels.serviceChannelDescription,
id: foregroundServiceNotificationChannelId,
importance: NotificationChannelImportance.MIN,
showBadge: false,
vibration: false,
enableLights: false,
),
]);
}

View File

@ -1,28 +1,31 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/services.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/lifecycle.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/pigeon/api.g.dart' as api;
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart' as modelc;
import 'package:moxxyv2/shared/models/message.dart' as modelm;
import 'package:moxxyv2/shared/models/notification.dart' as modeln;
import 'package:moxxyv2/shared/thumbnails/helpers.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
const _maxNotificationId = 2147483647;
const _messageChannelKey = 'message_channel';
const _warningChannelKey = 'warning_channel';
/// Message payload keys.
const _conversationJidKey = 'conversationJid';
@ -31,14 +34,27 @@ const _conversationTitleKey = 'title';
const _conversationAvatarKey = 'avatarPath';
class NotificationsService {
NotificationsService() {
_eventStream = _channel
.receiveBroadcastStream()
.cast<Object>()
.map(api.NotificationEvent.decode);
}
/// Logging.
final Logger _log = Logger('NotificationsService');
/// The Pigeon channel to the native side
final api.MoxxyApi _api = api.MoxxyApi();
final EventChannel _channel =
const EventChannel('org.moxxy.moxxyv2/notification_stream');
late final Stream<api.NotificationEvent> _eventStream;
/// Called when something happens to the notification, i.e. the actions are triggered or
/// the notification has been tapped.
Future<void> onNotificationEvent(NotificationEvent event) async {
Future<void> onNotificationEvent(api.NotificationEvent event) async {
final conversationJid = event.extra![_conversationJidKey]!;
if (event.type == NotificationEventType.open) {
if (event.type == api.NotificationEventType.open) {
// The notification has been tapped
sendEvent(
MessageNotificationTappedEvent(
@ -47,12 +63,12 @@ class NotificationsService {
avatarPath: event.extra![_conversationAvatarKey]!,
),
);
} else if (event.type == NotificationEventType.markAsRead) {
} else if (event.type == api.NotificationEventType.markAsRead) {
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
// Mark the message as read
await GetIt.I.get<MessageService>().markMessageAsRead(
event.extra![_messageIdKey]!,
accountJid,
accountJid!,
// [XmppService.sendReadMarker] will check whether the *SHOULD* send
// the marker, i.e. if the privacy settings allow it.
true,
@ -83,7 +99,7 @@ class NotificationsService {
// Clear notifications
await dismissNotificationsByJid(conversationJid, accountJid);
} else if (event.type == NotificationEventType.reply) {
} else if (event.type == api.NotificationEventType.reply) {
// Save this as a notification so that we can display it later
assert(
event.payload != null,
@ -93,7 +109,7 @@ class NotificationsService {
final notification = modeln.Notification(
event.id,
conversationJid,
accountJid,
accountJid!,
null,
null,
null,
@ -119,8 +135,8 @@ class NotificationsService {
/// Configures the translatable strings on the native side
/// using locale is currently configured.
Future<void> configureNotificationI18n() async {
await MoxplatformPlugin.notifications.setI18n(
NotificationI18nData(
await _api.setNotificationI18n(
api.NotificationI18nData(
reply: t.notifications.message.reply,
markAsRead: t.notifications.message.markAsRead,
you: t.messages.you,
@ -129,32 +145,68 @@ class NotificationsService {
}
Future<void> initialize() async {
// Set up notification groups
await _api.createNotificationGroups(
[
api.NotificationGroup(
id: messageNotificationGroupId,
description: 'Chat messages',
),
api.NotificationGroup(
id: warningNotificationChannelId,
description: 'Warnings',
),
api.NotificationGroup(
id: foregroundServiceNotificationGroupId,
description: 'Foreground service',
),
],
);
// Set up the notitifcation channels.
await MoxplatformPlugin.notifications.createNotificationChannel(
t.notifications.channels.messagesChannelName,
t.notifications.channels.messagesChannelDescription,
_messageChannelKey,
true,
);
await MoxplatformPlugin.notifications.createNotificationChannel(
t.notifications.channels.warningChannelName,
t.notifications.channels.warningChannelDescription,
_warningChannelKey,
false,
);
await _api.createNotificationChannels([
api.NotificationChannel(
title: t.notifications.channels.messagesChannelName,
description: t.notifications.channels.messagesChannelDescription,
id: messageNotificationChannelId,
importance: api.NotificationChannelImportance.HIGH,
showBadge: true,
vibration: true,
enableLights: true,
),
api.NotificationChannel(
title: t.notifications.channels.warningChannelName,
description: t.notifications.channels.warningChannelDescription,
id: warningNotificationChannelId,
importance: api.NotificationChannelImportance.DEFAULT,
showBadge: false,
vibration: true,
enableLights: false,
),
// The foreground notification channel is only required on Android
if (Platform.isAndroid)
api.NotificationChannel(
title: t.notifications.channels.serviceChannelName,
description: t.notifications.channels.serviceChannelDescription,
id: foregroundServiceNotificationChannelId,
importance: api.NotificationChannelImportance.MIN,
showBadge: false,
vibration: false,
enableLights: false,
),
]);
// Configure i18n
await configureNotificationI18n();
// Listen to notification events
MoxplatformPlugin.notifications
.getEventStream()
.listen(onNotificationEvent);
_eventStream.listen(onNotificationEvent);
}
/// Returns true if a notification should be shown. false otherwise.
bool shouldShowNotification(String jid) {
return GetIt.I.get<XmppService>().getCurrentlyOpenedChatJid() != jid;
return GetIt.I.get<ConversationService>().activeConversationJid != jid ||
!GetIt.I.get<LifecycleService>().isActive;
}
/// Queries the notifications for the conversation [jid] from the database.
@ -215,12 +267,28 @@ class NotificationsService {
'File metadata has path but no mime type',
);
/// Use the resource (nick) when the chat is a groupchat
// Use the resource (nick) when the chat is a groupchat
final senderJid = m.senderJid;
final senderTitle = c.isGroupchat
? senderJid.resource
: await c.titleWithOptionalContactService;
// If the file is a video, use its thumbnail, if available
var filePath = m.fileMetadata?.path;
var fileMime = m.fileMetadata?.mimeType;
// Thumbnail workaround for Android
if (Platform.isAndroid &&
(m.fileMetadata?.mimeType?.startsWith('video/') ?? false) &&
m.fileMetadata?.path != null) {
final thumbnailPath = await getVideoThumbnailPath(m.fileMetadata!.path!);
if (File(thumbnailPath).existsSync()) {
// Workaround for Android to show the thumbnail in the notification
filePath = thumbnailPath;
fileMime = 'image/jpeg';
}
}
// Add to the database
final newNotification = modeln.Notification(
id,
@ -230,8 +298,8 @@ class NotificationsService {
senderJid.toString(),
(avatarPath?.isEmpty ?? false) ? null : avatarPath,
body,
m.fileMetadata?.mimeType,
m.fileMetadata?.path,
fileMime,
filePath,
m.timestamp,
);
await GetIt.I.get<DatabaseService>().database.insert(
@ -250,7 +318,7 @@ class NotificationsService {
/// When a notification is already visible, then build a new notification based on [c] and [m],
/// update the database state and tell the OS to show the notification again.
// TODO(Unknown): What about systems that cannot do this (Linux, OS X, Windows)?
Future<void> updateNotification(
Future<void> updateOrShowNotification(
modelc.Conversation c,
modelm.Message m,
String accountJid,
@ -263,37 +331,38 @@ class NotificationsService {
}
final notifications = await _getNotificationsForJid(c.jid, accountJid);
final id = notifications.first.id;
final id = notifications.isNotEmpty
? notifications.first.id
: Random().nextInt(_maxNotificationId);
// TODO(Unknown): Handle groupchat member avatars
final notification = await _createNotification(
c,
m,
accountJid,
c.isGroupchat ? null : c.avatarPathWithOptionalContact,
c.isGroupchat ? null : await c.avatarPathWithOptionalContactService,
id,
shouldOverride: true,
);
await MoxplatformPlugin.notifications.showMessagingNotification(
MessagingNotification(
await _api.showMessagingNotification(
api.MessagingNotification(
title: await c.titleWithOptionalContactService,
id: id,
channelId: _messageChannelKey,
channelId: messageNotificationChannelId,
jid: c.jid,
messages: [
...notifications.map((n) {
// Based on the table's composite primary key
if (n.id == notification.id &&
n.conversationJid == notification.conversationJid &&
n.senderJid == notification.senderJid &&
n.timestamp == notification.timestamp) {
return notification.toNotificationMessage();
}
messages: notifications.map((n) {
// Based on the table's composite primary key
if (n.id == notification.id &&
n.conversationJid == notification.conversationJid &&
n.senderJid == notification.senderJid &&
n.timestamp == notification.timestamp) {
return notification.toNotificationMessage();
}
return n.toNotificationMessage();
}),
],
return n.toNotificationMessage();
}).toList(),
isGroupchat: c.isGroupchat,
groupId: messageNotificationGroupId,
extra: {
_conversationJidKey: c.jid,
_messageIdKey: m.id,
@ -325,11 +394,11 @@ class NotificationsService {
final id = notifications.isNotEmpty
? notifications.first.id
: Random().nextInt(_maxNotificationId);
await MoxplatformPlugin.notifications.showMessagingNotification(
MessagingNotification(
await _api.showMessagingNotification(
api.MessagingNotification(
title: title,
id: id,
channelId: _messageChannelKey,
channelId: messageNotificationChannelId,
jid: c.jid,
messages: [
...notifications.map((n) => n.toNotificationMessage()),
@ -344,6 +413,7 @@ class NotificationsService {
.toNotificationMessage(),
],
isGroupchat: c.isGroupchat,
groupId: messageNotificationGroupId,
extra: {
_conversationJidKey: c.jid,
_messageIdKey: m.id,
@ -364,13 +434,14 @@ class NotificationsService {
return;
}
await MoxplatformPlugin.notifications.showNotification(
RegularNotification(
await _api.showNotification(
api.RegularNotification(
title: title,
body: body,
channelId: _warningChannelKey,
channelId: warningNotificationChannelId,
id: Random().nextInt(_maxNotificationId),
icon: NotificationIcon.warning,
icon: api.NotificationIcon.warning,
groupId: warningNotificationGroupId,
),
);
}
@ -401,14 +472,15 @@ class NotificationsService {
final conversation = await GetIt.I
.get<ConversationService>()
.getConversationByJid(jid, accountJid);
await MoxplatformPlugin.notifications.showNotification(
RegularNotification(
await _api.showNotification(
api.RegularNotification(
title: t.notifications.errors.messageError.title,
body: t.notifications.errors.messageError
.body(conversationTitle: conversation!.title),
channelId: _warningChannelKey,
channelId: warningNotificationChannelId,
id: Random().nextInt(_maxNotificationId),
icon: NotificationIcon.error,
icon: api.NotificationIcon.error,
groupId: warningNotificationGroupId,
),
);
}
@ -418,7 +490,7 @@ class NotificationsService {
Future<void> dismissNotificationsByJid(String jid, String accountJid) async {
final id = await _clearNotificationsForJid(jid, accountJid);
if (id != null) {
await MoxplatformPlugin.notifications.dismissNotification(id);
await _api.dismissNotification(id);
}
}
@ -435,8 +507,7 @@ class NotificationsService {
// Dismiss the notification
for (final idRaw in ids) {
await MoxplatformPlugin.notifications
.dismissNotification(idRaw['id']! as int);
await _api.dismissNotification(idRaw['id']! as int);
}
// Remove database entries
@ -450,11 +521,10 @@ class NotificationsService {
/// Requests the avatar path from [XmppStateService] and configures the notification plugin
/// accordingly, if the avatar path is not null. If it is null, this method does nothing.
Future<void> maybeSetAvatarFromState() async {
final avatarPath =
(await GetIt.I.get<XmppStateService>().getXmppState()).avatarUrl;
final xss = GetIt.I.get<XmppStateService>();
final avatarPath = (await xss.state).avatarUrl;
if (avatarPath.isNotEmpty) {
await MoxplatformPlugin.notifications
.setNotificationSelfAvatar(avatarPath);
await _api.setNotificationSelfAvatar(avatarPath);
}
}
}

View File

@ -9,7 +9,7 @@ class PermissionsService {
/// `askedNotificationPermission` to true.
Future<bool> shouldRequestNotificationPermission() async {
final xss = GetIt.I.get<XmppStateService>();
final retValue = !(await xss.getXmppState()).askedNotificationPermission;
final retValue = !(await xss.state).askedNotificationPermission;
if (retValue) {
await xss.modifyXmppState(
(state) => state.copyWith(askedNotificationPermission: true),
@ -29,8 +29,7 @@ class PermissionsService {
}
final xss = GetIt.I.get<XmppStateService>();
final retValue =
!(await xss.getXmppState()).askedBatteryOptimizationExcemption;
final retValue = !(await xss.state).askedBatteryOptimizationExcemption;
if (retValue) {
await xss.modifyXmppState(
(state) => state.copyWith(askedBatteryOptimizationExcemption: true),

View File

@ -135,6 +135,7 @@ class ReactionsService {
return null;
}
final xss = GetIt.I.get<XmppStateService>();
await GetIt.I.get<DatabaseService>().database.delete(
reactionsTable,
where:
@ -143,7 +144,7 @@ class ReactionsService {
id,
accountJid,
emoji,
(await GetIt.I.get<XmppStateService>().getXmppState()).jid,
(await xss.state).jid,
],
);
final count = await _countReactions(id, accountJid, emoji);

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
@ -16,16 +17,19 @@ import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/cryptography/cryptography.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/migration.dart';
import 'package:moxxyv2/service/events.dart';
import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/groupchat.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/language.dart';
import 'package:moxxyv2/service/lifecycle.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/moxxmpp/connectivity.dart';
import 'package:moxxyv2/service/moxxmpp/roster.dart';
import 'package:moxxyv2/service/moxxmpp/socket.dart';
import 'package:moxxyv2/service/moxxmpp/stream.dart';
import 'package:moxxyv2/service/non_database_migrations/0000_notification_channels.dart';
import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/permissions.dart';
@ -70,10 +74,30 @@ Future<void> initializeServiceIfNeeded() async {
);
} else {
logger.info('Service is not running. Initializing service... ');
// Run non-db migrations
const storage = FlutterSecureStorage();
const versionKey = 'non_database_migrations_version';
final currentVersion = int.parse(
await storage.read(key: versionKey) ?? '0',
);
await runMigrations(
logger,
42,
const [
Migration(2, upgradeV1ToV2NonDb),
],
currentVersion,
'non-database',
commitVersion: (version) async =>
storage.write(key: versionKey, value: version.toString()),
);
await handler.start(
entrypoint,
receiveUIEvent,
ui_events.handleIsolateEvent,
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
);
}
}
@ -145,7 +169,7 @@ Future<void> initUDPLogger() async {
/// The entrypoint for all platforms after the platform specific initilization is done.
@pragma('vm:entry-point')
Future<void> entrypoint() async {
Future<void> entrypoint(String initialLocale) async {
setupLogging();
setupBackgroundEventHandler();
@ -159,6 +183,9 @@ Future<void> entrypoint() async {
GetIt.I.registerSingleton<DatabaseService>(DatabaseService());
await GetIt.I.get<DatabaseService>().initialize();
// Initialize the account state
await GetIt.I.get<XmppStateService>().initializeXmppState();
// Initialize services
GetIt.I.registerSingleton<ConnectivityWatcherService>(
ConnectivityWatcherService(),
@ -182,9 +209,27 @@ Future<void> entrypoint() async {
GetIt.I.registerSingleton<StorageService>(StorageService());
GetIt.I.registerSingleton<ShareService>(ShareService());
GetIt.I.registerSingleton<PermissionsService>(PermissionsService());
GetIt.I.registerSingleton<LifecycleService>(LifecycleService());
final xmpp = XmppService();
GetIt.I.registerSingleton<XmppService>(xmpp);
// Set the locale before we initialize the notigications service to ensure
// the correct locale is used for the notification channels.
final preferredLocale =
(await GetIt.I.get<PreferencesService>().getPreferences())
.languageLocaleCode;
if (preferredLocale == 'default') {
LocaleSettings.setLocaleRaw(initialLocale);
GetIt.I.get<Logger>().finest(
'Setting locale to system locale ($initialLocale) per preferences',
);
} else {
LocaleSettings.setLocaleRaw(preferredLocale);
GetIt.I.get<Logger>().finest(
'Setting locale to configured locale ($preferredLocale) per preferences',
);
}
await GetIt.I.get<NotificationsService>().initialize();
await GetIt.I.get<ContactsService>().initialize();
await GetIt.I.get<ConnectivityService>().initialize();
@ -233,7 +278,7 @@ Future<void> entrypoint() async {
(toJid, _) async =>
GetIt.I.get<ConversationService>().shouldEncryptForConversation(
toJid,
await GetIt.I.get<XmppStateService>().getAccountJid(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
),
),
PingManager(const Duration(minutes: 3)),

View File

@ -155,7 +155,8 @@ JOIN
);
// Retract from PubSub
final state = await GetIt.I.get<XmppStateService>().getXmppState();
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.state;
final result = await GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
@ -168,7 +169,8 @@ JOIN
Future<void> _publishStickerPack(moxxmpp.StickerPack pack) async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
final state = await GetIt.I.get<XmppStateService>().getXmppState();
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.state;
final result = await GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!

View File

@ -23,6 +23,7 @@ import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/lifecycle.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/notifications.dart';
@ -81,22 +82,15 @@ class XmppService {
/// Flag indicating whether a login was triggered from the UI or not.
bool _loginTriggeredFromUI = false;
/// Flag indicating whether the app is currently open or not.
bool _appOpen = true;
/// The JID of the currently opened chat. Empty, if no chat is opened.
String _currentlyOpenedChatJid = '';
/// Subscription to events by the XmppConnection
StreamSubscription<dynamic>? _xmppConnectionSubscription;
/// Stores whether the app is open or not. Useful for notifications.
void setAppState(bool open) {
_appOpen = open;
}
Future<ConnectionSettings?> getConnectionSettings() async {
final state = await GetIt.I.get<XmppStateService>().getXmppState();
final xss = GetIt.I.get<XmppStateService>();
final accountJid = await xss.getAccountJid();
if (accountJid == null) return null;
final state = await GetIt.I.get<XmppStateService>().state;
if (state.jid == null || state.password == null) {
return null;
@ -112,13 +106,11 @@ class XmppService {
/// greater than 0.
Future<void> setCurrentlyOpenedChatJid(String jid) async {
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final cs = GetIt.I.get<ConversationService>();
_currentlyOpenedChatJid = jid;
final cs = GetIt.I.get<ConversationService>()..activeConversationJid = jid;
final conversation = await cs.createOrUpdateConversation(
jid,
accountJid,
accountJid!,
update: (c) async {
if (c.unreadCounter > 0) {
return cs.updateConversation(
@ -139,9 +131,6 @@ class XmppService {
}
}
/// Returns the JID of the chat that is currently opened. Null, if none is open.
String? getCurrentlyOpenedChatJid() => _currentlyOpenedChatJid;
/// Sends a message correction to [recipient] regarding the message with stanza id
/// [oldId]. The old message's body gets corrected to [newBody]. [id] is the message's
/// id. [chatState] can be optionally specified to also include a chat state
@ -475,7 +464,7 @@ class XmppService {
bool triggeredFromUI,
) async {
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.getXmppState();
final state = await xss.state;
final conn = GetIt.I.get<XmppConnection>();
final lastResource = state.resource ?? '';
@ -503,8 +492,8 @@ class XmppService {
bool triggeredFromUI,
) async {
final xss = GetIt.I.get<XmppStateService>();
final state = await xss.getXmppState();
final conn = GetIt.I.get<XmppConnection>();
final state = await xss.state;
final lastResource = state.resource ?? '';
_loginTriggeredFromUI = triggeredFromUI;
@ -859,7 +848,7 @@ class XmppService {
.requestRoster();
await GetIt.I.get<BlocklistService>().getBlocklist(
await GetIt.I.get<XmppStateService>().getAccountJid(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
}
@ -913,7 +902,7 @@ class XmppService {
final rs = GetIt.I.get<RosterService>();
final rosterItem = await rs.getRosterItemByJid(
jid.toString(),
await GetIt.I.get<XmppStateService>().getAccountJid(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
if (rosterItem != null) {
final pm = GetIt.I
@ -941,7 +930,7 @@ class XmppService {
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final dbMsg = await ms.getMessageByStanzaId(
event.id,
accountJid,
accountJid!,
queryReactionPreview: false,
);
if (dbMsg == null) {
@ -976,7 +965,7 @@ class XmppService {
final sender = event.from.toBare().toString();
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
// TODO(Unknown): With groupchats, we should use the groupchat assigned stanza-id
final dbMsg = await ms.getMessageByStanzaId(event.id, accountJid);
final dbMsg = await ms.getMessageByStanzaId(event.id, accountJid!);
if (dbMsg == null) {
_log.warning('Did not find the message in the database!');
return;
@ -1009,7 +998,7 @@ class XmppService {
final cs = GetIt.I.get<ConversationService>();
final conversation = await cs.getConversationByJid(
jid,
await GetIt.I.get<XmppStateService>().getAccountJid(),
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
);
if (conversation == null) return;
@ -1281,7 +1270,7 @@ class XmppService {
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
if (event.type == 'error') {
await _handleErrorMessage(event, accountJid);
await _handleErrorMessage(event, accountJid!);
_log.finest('Processed error message. Ending event processing here.');
return;
}
@ -1294,7 +1283,7 @@ class XmppService {
// Process message corrections separately
if (event.extensions.get<LastMessageCorrectionData>() != null) {
await _handleMessageCorrection(event, conversationJid, accountJid);
await _handleMessageCorrection(event, conversationJid, accountJid!);
return;
}
@ -1303,19 +1292,19 @@ class XmppService {
await _handleFileUploadNotificationReplacement(
event,
conversationJid,
accountJid,
accountJid!,
);
return;
}
if (event.extensions.get<MessageRetractionData>() != null) {
await _handleMessageRetraction(event, conversationJid, accountJid);
await _handleMessageRetraction(event, conversationJid, accountJid!);
return;
}
// Handle message reactions
if (event.extensions.get<MessageReactionsData>() != null) {
await _handleMessageReactions(event, conversationJid, accountJid);
await _handleMessageReactions(event, conversationJid, accountJid!);
return;
}
@ -1332,12 +1321,12 @@ class XmppService {
return;
}
final state = await GetIt.I.get<XmppStateService>().getXmppState();
final state = await GetIt.I.get<XmppStateService>().state;
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
// The (portential) roster item of the chat partner
final rosterItem = await GetIt.I
.get<RosterService>()
.getRosterItemByJid(conversationJid, accountJid);
.getRosterItemByJid(conversationJid, accountJid!);
// Is the conversation partner in our roster
final isInRoster = rosterItem != null;
// True if the message was sent by us (via a Carbon)
@ -1524,7 +1513,7 @@ class XmppService {
? mimeTypeToEmoji(mimeGuess)
: messageBody;
// Specifies if we have the conversation this message goes to opened
final isConversationOpened = _currentlyOpenedChatJid == conversationJid;
final isConversationOpened = cs.activeConversationJid == conversationJid;
// If the conversation is muted
var isMuted = false;
// Whether to send the notification
@ -1588,7 +1577,8 @@ class XmppService {
isMuted = c != null ? c.muted : prefs.defaultMuteState;
sendNotification = !sent &&
shouldNotify &&
(!isConversationOpened || !_appOpen) &&
(!isConversationOpened ||
!GetIt.I.get<LifecycleService>().isActive) &&
!isMuted;
},
);
@ -1730,7 +1720,7 @@ class XmppService {
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
final msg = await ms.getMessageByStanzaId(event.stanza.id!, accountJid);
final msg = await ms.getMessageByStanzaId(event.stanza.id!, accountJid!);
if (msg != null) {
// Ack the message
final newMsg = await ms.updateMessage(
@ -1790,7 +1780,7 @@ class XmppService {
final ms = GetIt.I.get<MessageService>();
final message = await ms.getMessageByStanzaId(
event.data.stanza.id!,
accountJid,
accountJid!,
);
if (message == null) {

View File

@ -6,6 +6,7 @@ import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/models/xmpp_state.dart';
import 'package:random_string/random_string.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
@ -30,7 +31,9 @@ class XmppStateService {
final Logger _log = Logger('XmppStateService');
/// Persistent state around the connection, like the SM token, etc.
XmppState? _state;
late XmppState _state;
final Lock _stateLock = Lock();
Future<XmppState> get state => _stateLock.synchronized(() => _state);
/// Cached account JID.
String? _accountJid;
@ -88,6 +91,7 @@ class XmppStateService {
await db.insert(
xmppStateTable,
{
'accountJid': _accountJid,
'key': _userAgentKey,
'value': jsonEncode(_userAgent!.toJson()),
},
@ -111,32 +115,48 @@ class XmppStateService {
});
}
Future<XmppState> getXmppState() async {
if (_state != null) return _state!;
Future<void> initializeXmppState() async {
// NOTE: Called only once at the start so we don't have to worry about aquiring a lock
await _loadAccountJid();
final state = await _loadXmppState(_accountJid);
if (_accountJid == null || state == null) {
_log.finest(
'No account JID or account state available. Creating default value',
);
_state = XmppState(jid: _accountJid);
return;
}
_state = state;
}
Future<XmppState?> _loadXmppState(String? accountJid) async {
if (accountJid == null) {
return null;
}
final json = <String, String?>{};
final rowsRaw = await GetIt.I.get<DatabaseService>().database.query(
xmppStateTable,
where: 'accountJid = ?',
whereArgs: [await getAccountJid()],
whereArgs: [accountJid],
columns: ['key', 'value'],
);
if (rowsRaw.isEmpty) {
return null;
}
for (final row in rowsRaw) {
json[row['key']! as String] = row['value'] as String?;
}
_log.finest(json);
_state = XmppState.fromDatabaseTuples(json);
return _state!;
return XmppState.fromDatabaseTuples(json);
}
/// A wrapper to modify the [XmppState] and commit it.
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
_state = func(_state!);
final accountJid = await getAccountJid();
/// The same as [commitXmppState] but without aquiring [_stateLock].
Future<void> _commitXmppState(String accountJid) async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
for (final tuple in _state!.toDatabaseTuples().entries) {
for (final tuple in _state.toDatabaseTuples().entries) {
batch.insert(
xmppStateTable,
<String, String?>{
@ -150,6 +170,43 @@ class XmppStateService {
await batch.commit();
}
Future<void> commitXmppState(String accountJid) async {
await _stateLock.synchronized(
() => _commitXmppState(accountJid),
);
}
Future<void> setXmppState(XmppState state, String accountJid) async {
await _stateLock.synchronized(
() async {
_state = state;
await _commitXmppState(accountJid);
},
);
}
/// A wrapper to modify the [XmppState] and commit it.
Future<void> modifyXmppState(
XmppState Function(XmppState) func, {
bool commit = true,
}) async {
final accountJid = await getAccountJid();
assert(
accountJid != null,
'The accountJid must be not empty',
);
await _stateLock.synchronized(
() async {
_state = func(_state);
if (commit) {
await _commitXmppState(accountJid!);
}
},
);
}
/// Resets the current account JID to null.
Future<void> resetAccountJid() async {
_accountJid = null;
@ -157,26 +214,29 @@ class XmppStateService {
}
/// Sets the current account JID to [jid] and stores it in the secure storage.
Future<void> setAccountJid(String jid) async {
Future<void> setAccountJid(String jid, {bool commit = true}) async {
_accountJid = jid;
await _storage.write(key: _accountJidKey, value: jid);
if (commit) {
await _storage.write(key: _accountJidKey, value: jid);
}
}
Future<String?> _loadAccountJid() async {
return _accountJid ??= await _storage.read(key: _accountJidKey);
}
/// Returns a string if we have an account jid and null if we don't.
Future<String?> getRawAccountJid() async {
if (_accountJid != null) {
return _accountJid;
/// Gets the current account JID from the cache or from the secure storage.
Future<String?> getAccountJid() async {
return _accountJid ?? await _loadAccountJid();
}
Future<bool> isLoggedIn(String? accountJid) async {
final s = await state;
if (accountJid == null || s.jid == null || s.password == null) {
return false;
}
return _loadAccountJid();
}
/// Gets the current account JID from the cache or from the secure storage.
Future<String> getAccountJid() async {
return _accountJid ?? (await _loadAccountJid())!;
return await GetIt.I.get<XmppService>().getConnectionSettings() != null;
}
}

View File

@ -23,4 +23,14 @@ const maxStickerPackPages = 2;
/// An "invalid" fake JID to make share_handler happy when adding the self-chat
/// to the direct share list.
const String selfChatShareFakeJid = '{{ self-chat }}';
const selfChatShareFakeJid = '{{ self-chat }}';
/// Keys for grouping notifications
const messageNotificationGroupId = 'message';
const warningNotificationGroupId = 'warning';
const foregroundServiceNotificationGroupId = 'service';
/// Notification channel ids
const foregroundServiceNotificationChannelId = 'foreground_service';
const messageNotificationChannelId = 'messages';
const warningNotificationChannelId = 'warnings';

View File

@ -8,7 +8,6 @@ import 'package:moxxyv2/shared/models/message.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:synchronized/synchronized.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
/// Add a leading zero, if required, to ensure that an integer is rendered
/// as a two "digit" string.
@ -366,49 +365,13 @@ Future<Size?> getImageSizeFromData(Uint8List bytes) async {
}
}
/// Generate a thumbnail file (JPEG) for the video at [path]. [conversationJid] refers
/// to the JID of the conversation the file comes from.
/// If the thumbnail already exists, then just its path is returned. If not, then
/// it gets generated first.
Future<String?> getVideoThumbnailPath(
String path,
String conversationJid,
String mime,
) async {
//print('getVideoThumbnailPath: Mime type: $mime');
// Ignore mime types that may be wacky
if (mime == 'video/webm') return null;
final tempDir = await getTemporaryDirectory();
final thumbnailFilenameNoExtension = p.withoutExtension(
p.basename(path),
);
final thumbnailFilename = '$thumbnailFilenameNoExtension.jpg';
final thumbnailDirectory = p.join(
tempDir.path,
'thumbnails',
conversationJid,
);
final thumbnailPath = p.join(thumbnailDirectory, thumbnailFilename);
final dir = Directory(thumbnailDirectory);
if (!dir.existsSync()) await dir.create(recursive: true);
final file = File(thumbnailPath);
if (file.existsSync()) return thumbnailPath;
final r = await VideoThumbnail.thumbnailFile(
video: path,
thumbnailPath: thumbnailDirectory,
imageFormat: ImageFormat.JPEG,
quality: 75,
);
assert(
r == thumbnailPath,
'The generated video thumbnail has a different path than we expected: $r vs. $thumbnailPath',
);
return thumbnailPath;
/// Returns true if we can generate a video thumbnail of mime type [mime]. If not, returns
/// false.
bool canGenerateVideoThumbnail(String mime) {
return ![
// Ignore mime types that may be wacky
'video/webm',
].contains(mime);
}
Future<String> getContactProfilePicturePath(String id) async {

View File

@ -62,6 +62,23 @@ enum ConversationType {
throw Exception();
}
}
/// Returns the "type" attribute value for a message within the enum value's
/// context.
String toMessageType() {
assert(
this != ConversationType.note,
'Chat states should not be sent to the self-chat',
);
switch (this) {
case ConversationType.note:
case ConversationType.chat:
return 'chat';
case ConversationType.groupchat:
return 'groupchat';
}
}
}
class ConversationTypeConverter

View File

@ -1,5 +1,5 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/service/pigeon/api.g.dart' as api;
part 'notification.freezed.dart';
part 'notification.g.dart';
@ -44,12 +44,12 @@ class Notification with _$Notification {
factory Notification.fromJson(Map<String, dynamic> json) =>
_$NotificationFromJson(json);
NotificationMessage toNotificationMessage() {
return NotificationMessage(
api.NotificationMessage toNotificationMessage() {
return api.NotificationMessage(
sender: sender,
jid: senderJid,
avatarPath: avatarPath,
content: NotificationMessageContent(
content: api.NotificationMessageContent(
body: body,
mime: mime,
path: path,

View File

@ -97,4 +97,6 @@ class XmppState with _$XmppState {
askedBatteryOptimizationExcemption ? 'true' : 'false',
};
}
bool get canLogIn => jid != null && password != null;
}

View File

@ -0,0 +1,41 @@
import 'dart:io';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:path/path.dart' as p;
Future<String> getVideoThumbnailPath(String path) async {
final tempDir = await MoxplatformPlugin.platform.getCacheDataPath();
final thumbnailFilenameNoExtension = p.withoutExtension(
p.basename(path),
);
final thumbnailFilename = '$thumbnailFilenameNoExtension.jpg';
final thumbnailDirectory = p.join(
tempDir,
'thumbnails',
);
return p.join(thumbnailDirectory, thumbnailFilename);
}
/// Generate a thumbnail file (JPEG) for the video at [path].
/// If the thumbnail already exists, then just its path is returned. If not, then
/// it gets generated first.
Future<String?> maybeGenerateVideoThumbnail(
String path,
) async {
final thumbnailPath = await getVideoThumbnailPath(path);
final thumbnailDirectory = p.dirname(thumbnailPath);
final dir = Directory(thumbnailDirectory);
if (!dir.existsSync()) await dir.create(recursive: true);
final file = File(thumbnailPath);
if (file.existsSync()) return thumbnailPath;
final success = await MoxplatformPlugin.platform
.generateVideoThumbnail(path, thumbnailPath, 720);
if (!success) {
GetIt.I.get<Logger>().warning('Failed to generate thumbnail for $path');
return null;
}
return thumbnailPath;
}

View File

@ -22,6 +22,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
on<ConversationClosedEvent>(_onConversationClosed);
on<ConversationMarkedAsReadEvent>(_onConversationMarkedAsRead);
on<ConversationsSetEvent>(_onConversationsSet);
on<ConversationExitedEvent>(_onConversationExited);
}
// TODO(Unknown): This pattern is used so often that it should become its own thing in moxlib
@ -70,6 +71,18 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
});
}
Future<void> _onConversationExited(
ConversationExitedEvent event,
Emitter<ConversationsState> emit,
) async {
await MoxplatformPlugin.handler.getDataSender().sendData(
ExitConversationCommand(
conversationType: event.type.toString(),
),
awaitable: false,
);
}
Future<void> _onConversationsAdded(
ConversationsAddedEvent event,
Emitter<ConversationsState> emit,

View File

@ -16,6 +16,14 @@ class ConversationsInitEvent extends ConversationsEvent {
final List<Conversation> conversations;
}
/// Triggered when a conversation has been exited
class ConversationExitedEvent extends ConversationsEvent {
ConversationExitedEvent(this.type);
/// The type of the conversation that we just exited.
final ConversationType type;
}
/// Triggered when a conversation has been added.
class ConversationsAddedEvent extends ConversationsEvent {
ConversationsAddedEvent(this.conversation);

View File

@ -7,6 +7,7 @@ import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/request_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/service/data.dart';
@ -106,6 +107,16 @@ class LoginBloc extends Bloc<LoginEvent, LoginState> {
(_) => false,
),
);
GetIt.I.get<RequestBloc>().add(
RequestsSetEvent(
[
if (result.preStart.requestNotificationPermission)
Request.notifications,
if (result.preStart.excludeFromBatteryOptimisation)
Request.batterySavingExcemption,
],
),
);
} else if (result is LoginFailureEvent) {
GetIt.I.get<UIDataService>().isLoggedIn = false;
return emit(

View File

@ -568,9 +568,6 @@ class BidirectionalConversationController
_textController.dispose();
_audioRecorder.dispose();
// Tell the contact that we're gone
_updateChatState(ChatState.gone);
super.dispose();
}
}

View File

@ -12,6 +12,7 @@ import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/pages/conversation/blink.dart';
@ -373,6 +374,13 @@ class ConversationPageState extends State<ConversationPage>
// Clear the read marker cache
GetIt.I.get<UIReadMarkerService>().clear();
// Tell the backend that the chat is no longer open
GetIt.I.get<ConversationsBloc>().add(
ConversationExitedEvent(
ConversationType.fromString(widget.conversationType),
),
);
return true;
},
child: KeyboardReplacerScaffold(

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/request_bloc.dart';
@ -39,18 +40,18 @@ class RequestDialog extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
onPressed: () async {
switch (request) {
case Request.notifications:
Permission.notification.request();
await Permission.notification.request();
break;
case Request.batterySavingExcemption:
MoxplatformPlugin.platform
await MoxplatformPlugin.platform
.openBatteryOptimisationSettings();
break;
}
context.read<RequestBloc>().add(NextRequestEvent());
GetIt.I.get<RequestBloc>().add(NextRequestEvent());
},
child: Text(t.permissions.allow),
),

View File

@ -1,8 +1,15 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/thumbnails/helpers.dart';
import 'package:moxxyv2/ui/widgets/shimmer.dart';
Future<String?> _videoThumbnailWrapper(String path, String mime) async {
if (!canGenerateVideoThumbnail(mime)) return null;
return maybeGenerateVideoThumbnail(path);
}
class VideoThumbnail extends StatelessWidget {
const VideoThumbnail({
required this.path,
@ -21,7 +28,7 @@ class VideoThumbnail extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder<String?>(
future: getVideoThumbnailPath(path, conversationJid, mime),
future: _videoThumbnailWrapper(path, mime),
builder: (context, snapshot) {
Widget widget;
if (snapshot.hasData && snapshot.data != null) {

206
pigeon/api.dart Normal file
View File

@ -0,0 +1,206 @@
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/service/pigeon/api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/org/moxxy/moxxyv2/Api.kt',
kotlinOptions: KotlinOptions(
package: 'org.moxxy.moxxyv2',
),
),
)
class NotificationMessageContent {
const NotificationMessageContent(
this.body,
this.mime,
this.path,
);
/// The textual body of the message.
final String? body;
/// The path and mime type of the media to show.
final String? mime;
final String? path;
}
class NotificationMessage {
const NotificationMessage(
this.sender,
this.content,
this.jid,
this.timestamp,
this.avatarPath, {
this.groupId,
});
/// The grouping key for the notification.
final String? groupId;
/// The sender of the message.
final String? sender;
/// The jid of the sender.
final String? jid;
/// The body of the message.
final NotificationMessageContent content;
/// Milliseconds since epoch.
final int timestamp;
/// The path to the avatar to use
final String? avatarPath;
}
class MessagingNotification {
const MessagingNotification(this.title, this.id, this.jid, this.messages,
this.channelId, this.isGroupchat, this.extra,
{this.groupId});
/// The title of the conversation.
final String title;
/// The id of the notification.
final int id;
/// The id of the notification channel the notification should appear on.
final String channelId;
/// The JID of the chat in which the notifications happen.
final String jid;
/// Messages to show.
final List<NotificationMessage?> messages;
/// Flag indicating whether this notification is from a groupchat or not.
final bool isGroupchat;
/// The id for notification grouping.
final String? groupId;
/// Additional data to include.
final Map<String?, String?>? extra;
}
enum NotificationIcon {
warning,
error,
none,
}
class RegularNotification {
const RegularNotification(
this.title, this.body, this.channelId, this.id, this.icon,
{this.groupId});
/// The title of the notification.
final String title;
/// The body of the notification.
final String body;
/// The id of the channel to show the notification on.
final String channelId;
/// The id for notification grouping.
final String? groupId;
/// The id of the notification.
final int id;
/// The icon to use.
final NotificationIcon icon;
}
enum NotificationEventType {
markAsRead,
reply,
open,
}
class NotificationEvent {
const NotificationEvent(
this.id,
this.jid,
this.type,
this.payload,
this.extra,
);
/// The notification id.
final int id;
/// The JID the notification was for.
final String jid;
/// The type of event.
final NotificationEventType type;
/// An optional payload.
/// - type == NotificationType.reply: The reply message text.
/// Otherwise: undefined.
final String? payload;
/// Extra data. Only set when type == NotificationType.reply.
final Map<String?, String?>? extra;
}
class NotificationI18nData {
const NotificationI18nData(this.reply, this.markAsRead, this.you);
/// The content of the reply button.
final String reply;
/// The content of the "mark as read" button.
final String markAsRead;
/// The text to show when *you* reply.
final String you;
}
class NotificationGroup {
const NotificationGroup(this.id, this.description);
final String id;
final String description;
}
enum NotificationChannelImportance { MIN, HIGH, DEFAULT }
class NotificationChannel {
const NotificationChannel(
this.id,
this.title,
this.description, {
this.importance = NotificationChannelImportance.DEFAULT,
this.showBadge = true,
this.groupId,
this.vibration = true,
this.enableLights = true,
});
final String title;
final String description;
final String id;
final NotificationChannelImportance importance;
final bool showBadge;
final String? groupId;
final bool vibration;
final bool enableLights;
}
@HostApi()
abstract class MoxxyApi {
/// Notification APIs
void createNotificationGroups(List<NotificationGroup> groups);
void deleteNotificationGroups(List<String> ids);
void createNotificationChannels(List<NotificationChannel> channels);
void deleteNotificationChannels(List<String> ids);
void showMessagingNotification(MessagingNotification notification);
void showNotification(RegularNotification notification);
void dismissNotification(int id);
void setNotificationSelfAvatar(String path);
void setNotificationI18n(NotificationI18nData data);
// Stubs for generating event classes
void notificationStub(NotificationEvent event);
}

View File

@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8745ddb5f27423c6ba4cc3b182688407239fe38f73ef93a0db0a3497ddf4c2e6"
sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a
url: "https://pub.dev"
source: hosted
version: "46.0.0"
version: "61.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "2c93c461a00a27dad2849137304d32b3c6b79c75b1d10ec9547ce329de329524"
sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562
url: "https://pub.dev"
source: hosted
version: "4.6.0"
version: "5.13.0"
archive:
dependency: "direct main"
description:
@ -57,14 +57,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
awesome_notifications:
dependency: "direct main"
description:
name: awesome_notifications
sha256: "2b430c75cc879d6cfd52bb6eb2b5c1591ed425347816408cdcbd3f6916bba14c"
url: "https://pub.dev"
source: hosted
version: "0.7.4+1"
badges:
dependency: "direct main"
description:
@ -101,18 +93,18 @@ packages:
dependency: transitive
description:
name: build
sha256: "29a03af98de60b4eb9136acd56608a54e989f6da238a80af739415b05589d6df"
sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
version: "2.3.1"
build_config:
dependency: transitive
description:
name: build_config
sha256: ad77deb6e9c143a3f550fbb4c5c1e0c6aadabe24274898d06b9526c61b9cf4fb
sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.1.1"
build_daemon:
dependency: transitive
description:
@ -125,18 +117,18 @@ packages:
dependency: transitive
description:
name: build_resolvers
sha256: "9aae031a54ab0beebc30a888c93e900d15ae2fd8883d031dbfbd5ebdb57f5a4c"
sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20"
url: "https://pub.dev"
source: hosted
version: "2.0.9"
version: "2.2.1"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "361d73f37cd48c47a81a61421eb1cc4cfd2324516fbb52f1bc4c9a01834ef2de"
sha256: b0a8a7b8a76c493e85f1b84bffa0588859a06197863dba8c9036b15581fd9727
url: "https://pub.dev"
source: hosted
version: "2.1.11"
version: "2.3.3"
build_runner_core:
dependency: transitive
description:
@ -365,10 +357,10 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: "8aff82f9b26fd868992e5430335a9d773bfef01e1d852d7ba71bf4c5d9349351"
sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
version: "2.3.2"
dbus:
dependency: transitive
description:
@ -652,10 +644,10 @@ packages:
dependency: "direct dev"
description:
name: freezed
sha256: b8f1017d491344ef41045d3fe85950404c49e74eeaf84a84d7b8b67ac24dfb91
sha256: "20db669e3663fd0cbfb53729e513199fb08627ae40de0db85e0b0fe32f82bf82"
url: "https://pub.dev"
source: hosted
version: "2.1.0+1"
version: "2.1.1"
freezed_annotation:
dependency: "direct main"
description:
@ -668,10 +660,10 @@ packages:
dependency: transitive
description:
name: frontend_server_client
sha256: "4f4a162323c86ffc1245765cfe138872b8f069deb42f7dbb36115fa27f31469b"
sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "3.2.0"
get_it:
dependency: "direct main"
description:
@ -780,10 +772,10 @@ packages:
dependency: "direct dev"
description:
name: json_serializable
sha256: "0cec7060459254cf1ff980c08dedca6fa50917724a3c3ec8c5026cb88dee8238"
sha256: fd1bcfbf6f623e1dfcc60616f189a6ca540dba7b5917447be5dab754b3116932
url: "https://pub.dev"
source: hosted
version: "6.3.1"
version: "6.3.2"
keyboard_height_plugin:
dependency: "direct main"
description:
@ -893,26 +885,26 @@ packages:
dependency: "direct main"
description:
name: moxplatform
sha256: "68d96d411ce0c8ddf7f60a89b338edb88f4a9a5405545d925d1fbb784402f16a"
sha256: "02ac0ee17f30e2bc3914a3f177d97f02d9fd062bc69ad4d5dd17404d2829f2c7"
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
source: hosted
version: "0.1.17+4"
version: "0.1.17+6"
moxplatform_android:
dependency: transitive
description:
name: moxplatform_android
sha256: a0683b90030c5e4cd163dccf96c61a3aa513ace664a2c9a581d7970fbd683251
sha256: "6b46109ec5d2c4a43cc68ac3dcf0396e5237c5800671a7e2dbcd22f468a13e28"
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
source: hosted
version: "0.1.20"
version: "0.1.22"
moxplatform_platform_interface:
dependency: transitive
description:
name: moxplatform_platform_interface
sha256: "14143f2850673fcb56535380718aec7d7d1f817986851a29fd83a905c1ea94fd"
sha256: "27a6691d6913a291f7bc93d98c87eef491a4e47a76d3d804d1e0a2de7a5875f0"
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
source: hosted
version: "0.1.20"
version: "0.1.22"
moxxmpp:
dependency: "direct main"
description:
@ -933,10 +925,9 @@ packages:
moxxyv2_builders:
dependency: "direct main"
description:
name: moxxyv2_builders
sha256: f936974cfc6f80308ecd348889e365feed164887cb576caf8c82acadfa7cc0c9
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
source: hosted
path: "../moxxyv2_builders"
relative: true
source: path
version: "0.2.0"
native_imaging:
dependency: "direct main"
@ -1122,6 +1113,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
pigeon:
dependency: "direct dev"
description:
name: pigeon
sha256: "5a79fd0b10423f6b5705525e32015597f861c31220b522a67d1e6b580da96719"
url: "https://pub.dev"
source: hosted
version: "11.0.1"
pinenacl:
dependency: transitive
description:
@ -1451,18 +1450,18 @@ packages:
dependency: transitive
description:
name: source_gen
sha256: "00f8b6b586f724a8c769c96f1d517511a41661c0aede644544d8d86a1ab11142"
sha256: "373f96cf5a8744bc9816c1ff41cf5391bbdbe3d7a96fe98c622b6738a8a7bd33"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
version: "1.3.2"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "522d9b05c40ec14f479ce4428337d106c0465fedab42f514582c198460a784fe"
sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
version: "1.3.4"
source_map_stack_trace:
dependency: transitive
description:
@ -1712,14 +1711,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0+1"
video_thumbnail:
dependency: "direct main"
description:
name: video_thumbnail
sha256: "3455c189d3f0bb4e3fc2236475aa84fe598b9b2d0e08f43b9761f5bc44210016"
url: "https://pub.dev"
source: hosted
version: "0.5.3"
visibility_detector:
dependency: "direct main"
description:
@ -1788,10 +1779,10 @@ packages:
dependency: transitive
description:
name: yaml
sha256: "3cee79b1715110341012d27756d9bae38e650588acd38d3f3c610822e1337ace"
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "3.1.2"
sdks:
dart: ">=2.19.0 <3.0.0"
flutter: ">=3.7.3"

View File

@ -13,7 +13,6 @@ dependencies:
archive: 3.3.2
audiofileplayer: 2.1.1
auto_size_text: 3.0.0
awesome_notifications: 0.7.4+1
badges: 2.0.3
better_open_file: 3.6.3
bloc: 8.1.0
@ -65,7 +64,7 @@ dependencies:
version: ^0.2.0
moxplatform:
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: 0.1.17+4
version: 0.1.17+6
moxxmpp:
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: 0.4.0
@ -103,11 +102,10 @@ dependencies:
url_launcher: 6.1.5
#unifiedpush: 3.0.1
uuid: 3.0.5
video_thumbnail: 0.5.3
visibility_detector: 0.4.0+2
dev_dependencies:
build_runner: ^2.1.11
build_runner: ^2.3.3
flutter_launcher_icons: ^0.9.3
flutter_lints: ^2.0.1
#flutter_test:
@ -116,6 +114,7 @@ dev_dependencies:
#integration_test:
# sdk: flutter
json_serializable: ^6.3.1
pigeon: 11.0.1
slang_build_runner: 3.4.0
test: ^1.21.1
very_good_analysis: ^4.0.0
@ -133,14 +132,21 @@ dependency_overrides:
# path: ../moxxmpp/packages/moxxmpp_socket_tcp
# omemo_dart:
# path: ../../Personal/omemo_dart
# moxplatform:
# path: ../moxplatform/packages/moxplatform
# moxplatform_android:
# path: ../moxplatform/packages/moxplatform_android
# moxplatform_platform_interface:
# path: ../moxplatform/packages/moxplatform_platform_interface
moxxmpp:
git:
url: https://codeberg.org/moxxy/moxxmpp.git
rev: 864cc0e7474d98f691d87b7a0806f428bf98b790
path: packages/moxxmpp
moxxyv2_builders:
path: ../moxxyv2_builders
extra_licenses:
- name: undraw.co
@ -152,6 +158,11 @@ extra_licenses:
url: "https://invent.kde.org/melvo/xmpp-providers"
flutter:
plugin:
platforms:
android:
package: org.moxxy.moxxyv2
pluginClass: MainActivity
uses-material-design: true
fonts:
- family: RobotoMono

View File

@ -14,20 +14,21 @@ void main() {
Logger('TestLogger'),
1,
[
DatabaseMigration(4, (_) async {
Migration(4, (_) async {
expect(counter, 3);
counter++;
}),
DatabaseMigration(2, (_) async {
Migration(2, (_) async {
expect(counter, 1);
counter++;
}),
DatabaseMigration(3, (_) async {
Migration(3, (_) async {
expect(counter, 2);
counter++;
}),
],
1,
'test',
);
});
@ -37,21 +38,63 @@ void main() {
Logger('TestLogger'),
1,
[
DatabaseMigration(4, (_) async {
Migration(4, (_) async {
expect(counter, 3);
counter++;
}),
DatabaseMigration(2, (_) async {
Migration(2, (_) async {
// This must never be called
expect(true, false);
}),
DatabaseMigration(3, (_) async {
Migration(3, (_) async {
expect(counter, 2);
counter++;
}),
],
2,
'test',
);
});
test('Commit when a migration has run', () async {
var hasRun = false;
await runMigrations<int>(
Logger('TestLogger'),
1,
[
Migration(4, (_) async {}),
Migration(2, (_) async {}),
Migration(3, (_) async {}),
],
2,
'test',
commitVersion: (version) async {
expect(version, 4);
hasRun = true;
},
);
expect(hasRun, true);
});
test('Do not commit when no migration has run', () async {
var hasRun = false;
await runMigrations<int>(
Logger('TestLogger'),
1,
[
Migration(4, (_) async {}),
Migration(2, (_) async {}),
Migration(3, (_) async {}),
],
4,
'test',
commitVersion: (version) async {
hasRun = true;
},
);
expect(hasRun, false);
});
});
}