diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fa2c342af5..2624d97b6a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,16 @@ + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntentEventListener.kt b/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntentEventListener.kt new file mode 100644 index 0000000000..9151f63da2 --- /dev/null +++ b/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntentEventListener.kt @@ -0,0 +1,112 @@ +package com.zulip.flutter + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns + +class AndroidIntentEventListener : AndroidIntentEventsStreamHandler() { + private var eventSink: PigeonEventSink? = null + private val buffer = mutableListOf() + + override fun onListen(p0: Any?, sink: PigeonEventSink) { + eventSink = sink + buffer.forEach { eventSink!!.success(it) } + } + + private fun onEvent(event: AndroidIntentEvent) { + if (eventSink != null) { + eventSink?.success(event) + } else { + buffer.add(event) + } + } + + fun handleSend(context: Context, intent: Intent) { + val intentAction = intent.action + assert( + intentAction == Intent.ACTION_SEND + || intentAction == Intent.ACTION_SEND_MULTIPLE + ) + + // EXTRA_TEXT and EXTRA_STREAM are the text and file components of the + // content, respectively. The ACTION_SEND{,_MULTIPLE} docs say + // "either" / "or" will be present: + // https://developer.android.com/reference/android/content/Intent#ACTION_SEND + // But empirically both can be present, commonly, so we accept that form, + // interpreting it as an intent to share both kinds of data. + // + // Empirically, sometimes EXTRA_TEXT isn't something we think needs to be + // shared, like the URL of a file that's present in EXTRA_STREAM… but we + // shrug and include it anyway because we don't want to second-guess other + // apps' decisions about what to include; it's their responsibility. + + val extraText = intent.getStringExtra(Intent.EXTRA_TEXT) + val extraStream = when (intentAction) { + Intent.ACTION_SEND -> { + var extraStream: List? = null + // TODO(android-sdk-33) Remove the use of deprecated API. + @Suppress("DEPRECATION") val url = intent.getParcelableExtra(Intent.EXTRA_STREAM) + if (url != null) { + extraStream = listOf(getIntentSharedFile(context, url)) + } + extraStream + } + + Intent.ACTION_SEND_MULTIPLE -> { + var extraStream: MutableList? = null + // TODO(android-sdk-33) Remove the use of deprecated API. + @Suppress("DEPRECATION") val urls = + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + if (urls != null) { + extraStream = mutableListOf() + for (url in urls) { + val sharedFile = getIntentSharedFile(context, url) + extraStream.add(sharedFile) + } + } + extraStream + } + + else -> throw IllegalArgumentException("Unexpected value for intent.action: $intentAction") + } + + if (extraText == null && extraStream == null) { + throw Exception("Got unexpected ACTION_SEND* intent, with neither EXTRA_TEXT nor EXTRA_STREAM") + } + + onEvent( + AndroidIntentSendEvent( + action = intentAction, + extraText = extraText, + extraStream = extraStream, + ) + ) + } +} + +// A helper function to retrieve the shared file from the `content://` URL +// from the ACTION_SEND{_MULTIPLE} intent. +fun getIntentSharedFile(context: Context, url: Uri): IntentSharedFile { + val contentResolver = context.contentResolver + val mimeType = contentResolver.getType(url) + val name = contentResolver.query(url, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.getString(nameIndex) + } ?: ("unknown." + (mimeType?.split('/')?.last() ?: "bin")) + + class ResolverFailedException(msg: String) : RuntimeException(msg) + + val bytes = (contentResolver.openInputStream(url) + ?: throw ResolverFailedException("resolver.open… failed")) + .use { inputStream -> + inputStream.readBytes() + } + + return IntentSharedFile( + name = name, + mimeType = mimeType, + bytes = bytes + ) +} diff --git a/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt new file mode 100644 index 0000000000..7529e632c6 --- /dev/null +++ b/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt @@ -0,0 +1,203 @@ +// Autogenerated from Pigeon (v25.5.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package com.zulip.flutter + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object AndroidIntentsPigeonUtils { + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class IntentSharedFile ( + val name: String, + val mimeType: String? = null, + val bytes: ByteArray +) + { + companion object { + fun fromList(pigeonVar_list: List): IntentSharedFile { + val name = pigeonVar_list[0] as String + val mimeType = pigeonVar_list[1] as String? + val bytes = pigeonVar_list[2] as ByteArray + return IntentSharedFile(name, mimeType, bytes) + } + } + fun toList(): List { + return listOf( + name, + mimeType, + bytes, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is IntentSharedFile) { + return false + } + if (this === other) { + return true + } + return AndroidIntentsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Generated class from Pigeon that represents data sent in messages. + * This class should not be extended by any user class outside of the generated file. + */ +sealed class AndroidIntentEvent +/** Generated class from Pigeon that represents data sent in messages. */ +data class AndroidIntentSendEvent ( + val action: String, + val extraText: String? = null, + val extraStream: List? = null +) : AndroidIntentEvent() + { + companion object { + fun fromList(pigeonVar_list: List): AndroidIntentSendEvent { + val action = pigeonVar_list[0] as String + val extraText = pigeonVar_list[1] as String? + val extraStream = pigeonVar_list[2] as List? + return AndroidIntentSendEvent(action, extraText, extraStream) + } + } + fun toList(): List { + return listOf( + action, + extraText, + extraStream, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is AndroidIntentSendEvent) { + return false + } + if (this === other) { + return true + } + return AndroidIntentsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} +private open class AndroidIntentsPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + IntentSharedFile.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + AndroidIntentSendEvent.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is IntentSharedFile -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is AndroidIntentSendEvent -> { + stream.write(130) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +val AndroidIntentsPigeonMethodCodec = StandardMethodCodec(AndroidIntentsPigeonCodec()) + + +private class AndroidIntentsPigeonStreamHandler( + val wrapper: AndroidIntentsPigeonEventChannelWrapper +) : EventChannel.StreamHandler { + var pigeonSink: PigeonEventSink? = null + + override fun onListen(p0: Any?, sink: EventChannel.EventSink) { + pigeonSink = PigeonEventSink(sink) + wrapper.onListen(p0, pigeonSink!!) + } + + override fun onCancel(p0: Any?) { + pigeonSink = null + wrapper.onCancel(p0) + } +} + +interface AndroidIntentsPigeonEventChannelWrapper { + open fun onListen(p0: Any?, sink: PigeonEventSink) {} + + open fun onCancel(p0: Any?) {} +} + +class PigeonEventSink(private val sink: EventChannel.EventSink) { + fun success(value: T) { + sink.success(value) + } + + fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + sink.error(errorCode, errorMessage, errorDetails) + } + + fun endOfStream() { + sink.endOfStream() + } +} + +abstract class AndroidIntentEventsStreamHandler : AndroidIntentsPigeonEventChannelWrapper { + companion object { + fun register(messenger: BinaryMessenger, streamHandler: AndroidIntentEventsStreamHandler, instanceName: String = "") { + var channelName: String = "dev.flutter.pigeon.zulip.AndroidIntentsEventChannelApi.androidIntentEvents" + if (instanceName.isNotEmpty()) { + channelName += ".$instanceName" + } + val internalStreamHandler = AndroidIntentsPigeonStreamHandler(streamHandler) + EventChannel(messenger, channelName, AndroidIntentsPigeonMethodCodec).setStreamHandler(internalStreamHandler) + } + } +} + diff --git a/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt b/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt index 1829456362..cad696eecf 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt @@ -1,6 +1,42 @@ package com.zulip.flutter +import android.content.Intent import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine -class MainActivity: FlutterActivity() { +class MainActivity : FlutterActivity() { + private var androidIntentEventListener: AndroidIntentEventListener? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + androidIntentEventListener = AndroidIntentEventListener() + AndroidIntentEventsStreamHandler.register( + flutterEngine.dartExecutor.binaryMessenger, + androidIntentEventListener!! + ) + maybeHandleIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + if (maybeHandleIntent(intent)) { + return + } + super.onNewIntent(intent) + } + + /** Returns true just if we did handle the intent. */ + private fun maybeHandleIntent(intent: Intent?): Boolean { + intent ?: return false + when (intent.action) { + // Share-to-Zulip + Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE -> { + androidIntentEventListener!!.handleSend(this, intent) + return true + } + + // For other intents, let Flutter handle it. + else -> return false + } + } } diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 77200c01eb..8d6328faef 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -995,6 +995,10 @@ "@channelsPageTitle": { "description": "Title for the page with a list of subscribed channels." }, + "sharePageTitle": "Share", + "@sharePageTitle": { + "description": "Title for the page about sharing content received from other apps." + }, "channelsEmptyPlaceholder": "You are not subscribed to any channels yet.", "@channelsEmptyPlaceholder": { "description": "Centered text on the 'Channels' page saying that there is no content to show." @@ -1224,6 +1228,14 @@ "@errorReactionRemovingFailedTitle": { "description": "Error title when removing a message reaction fails" }, + "errorSharingTitle": "Failed to share content", + "@errorSharingTitle": { + "description": "Error title when sharing content received from other apps fails" + }, + "errorSharingAccountNotLoggedIn": "There is no account logged in. Please log in to an account and try again.", + "@errorSharingAccountNotLoggedIn": { + "description": "Error title when sharing content received from other apps fails, when there is no account logged in" + }, "emojiReactionsMore": "more", "@emojiReactionsMore": { "description": "Label for a button opening the emoji picker." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 8165cd0701..0035b3b557 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1487,6 +1487,12 @@ abstract class ZulipLocalizations { /// **'Channels'** String get channelsPageTitle; + /// Title for the page about sharing content received from other apps. + /// + /// In en, this message translates to: + /// **'Share'** + String get sharePageTitle; + /// Centered text on the 'Channels' page saying that there is no content to show. /// /// In en, this message translates to: @@ -1799,6 +1805,18 @@ abstract class ZulipLocalizations { /// **'Removing reaction failed'** String get errorReactionRemovingFailedTitle; + /// Error title when sharing content received from other apps fails + /// + /// In en, this message translates to: + /// **'Failed to share content'** + String get errorSharingTitle; + + /// Error title when sharing content received from other apps fails, when there is no account logged in + /// + /// In en, this message translates to: + /// **'There is no account logged in. Please log in to an account and try again.'** + String get errorSharingAccountNotLoggedIn; + /// Label for a button opening the emoji picker. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 5ff9981001..968bdfa514 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -811,6 +811,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -999,6 +1002,13 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 8d0826bac9..de07e7874b 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -832,6 +832,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanäle'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'Du hast noch keine Kanäle abonniert.'; @@ -1026,6 +1029,13 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get errorReactionRemovingFailedTitle => 'Entfernen der Reaktion fehlgeschlagen'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'mehr'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index bffdb9f9a9..ec641b7797 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -811,6 +811,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -999,6 +1002,13 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index 5001c79eed..32ef7b72c4 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -811,6 +811,9 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -999,6 +1002,13 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 25a5c4e999..8149552491 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -826,6 +826,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get channelsPageTitle => 'Canali'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'Non sei ancora iscritto ad alcun canale.'; @@ -1021,6 +1024,13 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get errorReactionRemovingFailedTitle => 'Rimozione della reazione non riuscita'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'altro'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index e39ebe4377..ca2eee8cb3 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -808,6 +808,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -996,6 +999,13 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index d08ca0eaf0..52e6e335f2 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -811,6 +811,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -999,6 +1002,13 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 9b856a6aae..bd04f2db82 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -822,6 +822,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanały'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'Nie śledzisz żadnego z kanałów.'; @@ -1012,6 +1015,13 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorReactionRemovingFailedTitle => 'Usuwanie reakcji bez powodzenia'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'więcej'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 848b02eefb..c7506d8ce9 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -825,6 +825,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get channelsPageTitle => 'Каналы'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'Вы еще не подписаны ни на один канал.'; @@ -1016,6 +1019,13 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Не удалось удалить реакцию'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'еще'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 96e9e0c542..82bbfb077a 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -813,6 +813,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanály'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -1001,6 +1004,13 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Odobranie reakcie zlyhalo'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'viac'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index bdb56cf44d..e9cf3035f4 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -837,6 +837,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanali'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'Niste še naročeni na noben kanal.'; @@ -1028,6 +1031,13 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get errorReactionRemovingFailedTitle => 'Reakcije ni bilo mogoče odstraniti'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'več'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 0c2f49363e..9ff5bbd8cf 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -825,6 +825,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get channelsPageTitle => 'Канали'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'Ви ще не підписані на жодний канал.'; @@ -1016,6 +1019,13 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Не вдалося видалити реакцію'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'більше'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index fdfd2966b5..a19ca6ebfb 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -811,6 +811,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -999,6 +1002,13 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; diff --git a/lib/host/android_intents.dart b/lib/host/android_intents.dart new file mode 100644 index 0000000000..6bd1e60de5 --- /dev/null +++ b/lib/host/android_intents.dart @@ -0,0 +1 @@ +export 'android_intents.g.dart'; diff --git a/lib/host/android_intents.g.dart b/lib/host/android_intents.g.dart new file mode 100644 index 0000000000..1e0b6e5e1a --- /dev/null +++ b/lib/host/android_intents.g.dart @@ -0,0 +1,174 @@ +// Autogenerated from Pigeon (v25.5.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == b; +} + + +class IntentSharedFile { + IntentSharedFile({ + required this.name, + this.mimeType, + required this.bytes, + }); + + String name; + + String? mimeType; + + Uint8List bytes; + + List _toList() { + return [ + name, + mimeType, + bytes, + ]; + } + + Object encode() { + return _toList(); } + + static IntentSharedFile decode(Object result) { + result as List; + return IntentSharedFile( + name: result[0]! as String, + mimeType: result[1] as String?, + bytes: result[2]! as Uint8List, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! IntentSharedFile || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +sealed class AndroidIntentEvent { +} + +class AndroidIntentSendEvent extends AndroidIntentEvent { + AndroidIntentSendEvent({ + required this.action, + this.extraText, + this.extraStream, + }); + + String action; + + String? extraText; + + List? extraStream; + + List _toList() { + return [ + action, + extraText, + extraStream, + ]; + } + + Object encode() { + return _toList(); } + + static AndroidIntentSendEvent decode(Object result) { + result as List; + return AndroidIntentSendEvent( + action: result[0]! as String, + extraText: result[1] as String?, + extraStream: (result[2] as List?)?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AndroidIntentSendEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is IntentSharedFile) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is AndroidIntentSendEvent) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return IntentSharedFile.decode(readValue(buffer)!); + case 130: + return AndroidIntentSendEvent.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec()); + +Stream androidIntentEvents( {String instanceName = ''}) { + if (instanceName.isNotEmpty) { + instanceName = '.$instanceName'; + } + final EventChannel androidIntentEventsChannel = + EventChannel('dev.flutter.pigeon.zulip.AndroidIntentsEventChannelApi.androidIntentEvents$instanceName', pigeonMethodCodec); + return androidIntentEventsChannel.receiveBroadcastStream().map((dynamic event) { + return event as AndroidIntentEvent; + }); +} + diff --git a/lib/main.dart b/lib/main.dart index f89ccb6040..6c23bca66a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'log.dart'; import 'model/binding.dart'; import 'notifications/receive.dart'; import 'widgets/app.dart'; +import 'widgets/share.dart'; void main() { assert(() { @@ -16,5 +17,6 @@ void main() { WidgetsFlutterBinding.ensureInitialized(); LiveZulipBinding.ensureInitialized(); NotificationService.instance.start(); + ShareService.start(); runApp(const ZulipApp()); } diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 2ad21d939b..2ef003fc0f 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -10,6 +10,7 @@ import 'package:package_info_plus/package_info_plus.dart' as package_info_plus; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:wakelock_plus/wakelock_plus.dart' as wakelock_plus; +import '../host/android_intents.dart' as android_intents_pigeon; import '../host/android_notifications.dart'; import '../host/notifications.dart' as notif_pigeon; import '../log.dart'; @@ -183,6 +184,8 @@ abstract class ZulipBinding { /// Wraps the [notif_pigeon.NotificationHostApi] class. NotificationPigeonApi get notificationPigeonApi; + Stream get androidIntentEvents; + /// Pick files from the media library, via package:file_picker. /// /// This wraps [file_picker.pickFiles]. @@ -488,6 +491,9 @@ class LiveZulipBinding extends ZulipBinding { @override NotificationPigeonApi get notificationPigeonApi => NotificationPigeonApi(); + @override + Stream get androidIntentEvents => android_intents_pigeon.androidIntentEvents(); + @override Future pickFiles({ bool allowMultiple = false, diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 7adfb2ba24..f344330e77 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -241,6 +241,7 @@ enum BottomSheetDismissButtonStyle { /// Needs a [PageRoot] ancestor. void showChannelActionSheet(BuildContext context, { required int channelId, + bool showTopicListButton = true, }) { final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(pageContext); @@ -254,7 +255,8 @@ void showChannelActionSheet(BuildContext context, { [ if (unreadCount > 0) MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId), - TopicListButton(pageContext: pageContext, channelId: channelId), + if (showTopicListButton) + TopicListButton(pageContext: pageContext, channelId: channelId), CopyChannelLinkButton(channelId: channelId, pageContext: pageContext) ], if (isSubscribed) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 99eeef3b0d..c8fa7976ee 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -911,8 +911,8 @@ class _EditMessageContentInput extends StatelessWidget { /// /// A convenience class to represent data from the generic file picker, /// the media library, and the camera, in a single form. -class _File { - _File({ +class FileToUpload { + FileToUpload({ required this.content, required this.length, required this.filename, @@ -929,14 +929,15 @@ Future _uploadFiles({ required BuildContext context, required ComposeContentController contentController, required FocusNode contentFocusNode, - required Iterable<_File> files, + bool shouldRequestFocus = true, + required Iterable files, }) async { assert(context.mounted); final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); - final List<_File> tooLargeFiles = []; - final List<_File> rightSizeFiles = []; + final List tooLargeFiles = []; + final List rightSizeFiles = []; for (final file in files) { if ((file.length / (1 << 20)) > store.maxFileUploadSizeMib) { tooLargeFiles.add(file); @@ -959,18 +960,18 @@ Future _uploadFiles({ listMessage)); } - final List<(int, _File)> uploadsInProgress = []; + final List<(int, FileToUpload)> uploadsInProgress = []; for (final file in rightSizeFiles) { final tag = contentController.registerUploadStart(file.filename, zulipLocalizations); uploadsInProgress.add((tag, file)); } - if (!contentFocusNode.hasFocus) { + if (shouldRequestFocus && !contentFocusNode.hasFocus) { contentFocusNode.requestFocus(); } for (final (tag, file) in uploadsInProgress) { - final _File(:content, :length, :filename, :mimeType) = file; + final FileToUpload(:content, :length, :filename, :mimeType) = file; String? url; try { final result = await uploadFile(store.connection, @@ -1009,7 +1010,7 @@ abstract class _AttachUploadsButton extends StatelessWidget { /// /// To signal exiting the interaction with no files chosen, /// return an empty [Iterable] after showing user feedback as appropriate. - Future> getFiles(BuildContext context); + Future> getFiles(BuildContext context); void _handlePress(BuildContext context) async { final files = await getFiles(context); @@ -1043,7 +1044,7 @@ abstract class _AttachUploadsButton extends StatelessWidget { } } -Future> _getFilePickerFiles(BuildContext context, FileType type) async { +Future> _getFilePickerFiles(BuildContext context, FileType type) async { FilePickerResult? result; try { result = await ZulipBinding.instance @@ -1088,7 +1089,7 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) f.path ?? '', headerBytes: f.bytes?.take(defaultMagicNumbersMaxLength).toList(), ); - return _File( + return FileToUpload( content: f.readStream!, length: f.size, filename: f.name, @@ -1108,7 +1109,7 @@ class _AttachFileButton extends _AttachUploadsButton { zulipLocalizations.composeBoxAttachFilesTooltip; @override - Future> getFiles(BuildContext context) async { + Future> getFiles(BuildContext context) async { return _getFilePickerFiles(context, FileType.any); } } @@ -1124,7 +1125,7 @@ class _AttachMediaButton extends _AttachUploadsButton { zulipLocalizations.composeBoxAttachMediaTooltip; @override - Future> getFiles(BuildContext context) async { + Future> getFiles(BuildContext context) async { // TODO(#114): This doesn't give quite the right UI on Android. return _getFilePickerFiles(context, FileType.media); } @@ -1141,7 +1142,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton { zulipLocalizations.composeBoxAttachFromCameraTooltip; @override - Future> getFiles(BuildContext context) async { + Future> getFiles(BuildContext context) async { final zulipLocalizations = ZulipLocalizations.of(context); final XFile? result; try { @@ -1192,7 +1193,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton { } catch (e) { // TODO(log) } - return [_File( + return [FileToUpload( content: result.openRead(), length: length, filename: result.name, @@ -1869,6 +1870,24 @@ abstract class ComposeBoxState extends State { /// Switch the compose box back to regular non-edit mode, with no content. void endEditInteraction(); + + /// Uploads the provided files, populating the content input with their links. + /// + /// If any of the files are larger than maximum file size allowed by the + /// server, an error dialog is shown mentioning their names and actual + /// file sizes. + /// + /// While uploading, a placeholder link is inserted in the content input and + /// if [shouldRequestFocus] is true it will be focused. And then after + /// uploading completes successfully the placeholder link will be replaced + /// with an actual link. + /// + /// If there is an error while uploading a file, then an error dialog is + /// shown mentioning the corresponding file name. + Future uploadFiles({ + required Iterable files, + required bool shouldRequestFocus, + }); } class _ComposeBoxState extends State with PerAccountStoreAwareStateMixin implements ComposeBoxState { @@ -2021,6 +2040,19 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM }); } + @override + Future uploadFiles({ + required Iterable files, + required bool shouldRequestFocus, + }) async { + await _uploadFiles( + context: context, + contentController: controller.content, + contentFocusNode: controller.contentFocusNode, + shouldRequestFocus: shouldRequestFocus, + files: files); + } + @override void onNewStore() { final newStore = PerAccountStoreWidget.of(context); diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index fa6c8b9229..4217d8cb73 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -133,7 +133,7 @@ class MessageListTheme extends ThemeExtension { /// The interface for the state of a [MessageListPage]. /// /// To obtain one of these, see [MessageListPage.ancestorOf]. -abstract class MessageListPageState { +abstract class MessageListPageState extends State { /// The narrow for this page's message list. Narrow get narrow; @@ -171,11 +171,20 @@ class MessageListPage extends StatefulWidget { this.initAnchorMessageId, }); - static AccountRoute buildRoute({int? accountId, BuildContext? context, - required Narrow narrow, int? initAnchorMessageId}) { - return MaterialAccountWidgetRoute(accountId: accountId, context: context, + static AccountRoute buildRoute({ + GlobalKey? key, + int? accountId, + BuildContext? context, + required Narrow narrow, + int? initAnchorMessageId, + }) { + return MaterialAccountWidgetRoute( + accountId: accountId, + context: context, page: MessageListPage( - initNarrow: narrow, initAnchorMessageId: initAnchorMessageId)); + key: key, + initNarrow: narrow, + initAnchorMessageId: initAnchorMessageId)); } /// The "revealed" state of a message from a muted sender, diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart index 93fbf5c354..05e682e849 100644 --- a/lib/widgets/new_dm_sheet.dart +++ b/lib/widgets/new_dm_sheet.dart @@ -6,14 +6,14 @@ import '../model/narrow.dart'; import '../model/store.dart'; import 'color.dart'; import 'icons.dart'; -import 'message_list.dart'; import 'page.dart'; +import 'recent_dm_conversations.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; import 'user.dart'; -void showNewDmSheet(BuildContext context) { +void showNewDmSheet(BuildContext context, OnDmSelectCallback onDmSelect) { final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(context); showModalBottomSheet( @@ -29,12 +29,14 @@ void showNewDmSheet(BuildContext context) { padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), child: PerAccountStoreWidget( accountId: store.accountId, - child: NewDmPicker()))); + child: NewDmPicker(onDmSelect: onDmSelect)))); } @visibleForTesting class NewDmPicker extends StatefulWidget { - const NewDmPicker({super.key}); + const NewDmPicker({super.key, required this.onDmSelect}); + + final OnDmSelectCallback onDmSelect; @override State createState() => _NewDmPickerState(); @@ -132,7 +134,7 @@ class _NewDmPickerState extends State with PerAccountStoreAwareStat @override Widget build(BuildContext context) { return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _NewDmHeader(selectedUserIds: selectedUserIds), + _NewDmHeader(selectedUserIds: selectedUserIds, onDmSelect: widget.onDmSelect), _NewDmSearchBar( controller: searchController, selectedUserIds: selectedUserIds, @@ -148,9 +150,10 @@ class _NewDmPickerState extends State with PerAccountStoreAwareStat } class _NewDmHeader extends StatelessWidget { - const _NewDmHeader({required this.selectedUserIds}); + const _NewDmHeader({required this.selectedUserIds, required this.onDmSelect}); final Set selectedUserIds; + final OnDmSelectCallback onDmSelect; Widget _buildCancelButton(BuildContext context) { final designVariables = DesignVariables.of(context); @@ -178,8 +181,7 @@ class _NewDmHeader extends StatelessWidget { final narrow = DmNarrow.withUsers( selectedUserIds.toList(), selfUserId: store.selfUserId); - Navigator.pushReplacement(context, - MessageListPage.buildRoute(context: context, narrow: narrow)); + onDmSelect(narrow); }, child: Text(zulipLocalizations.newDmSheetComposeButtonLabel, style: TextStyle( diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index f4846bf943..b287a084b2 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -14,8 +14,26 @@ import 'theme.dart'; import 'unread_count_badge.dart'; import 'user.dart'; +typedef OnDmSelectCallback = void Function(DmNarrow narrow); + class RecentDmConversationsPageBody extends StatefulWidget { - const RecentDmConversationsPageBody({super.key}); + const RecentDmConversationsPageBody({ + super.key, + this.hideDmsIfUserCantPost = false, + this.onDmSelect, + }); + + // TODO refactor this widget to avoid reuse of the whole page, + // avoiding the need for these flags, callback, and the below + // handling of safe-area at this level of abstraction. + // See discussion: + // https://github.com/zulip/zulip-flutter/pull/1774#discussion_r2249032503 + final bool hideDmsIfUserCantPost; + + /// Callback to invoke when the user selects a DM conversation from the list. + /// + /// If null, the default behavior is to navigate to the DM conversation. + final OnDmSelectCallback? onDmSelect; @override State createState() => _RecentDmConversationsPageBodyState(); @@ -50,12 +68,39 @@ class _RecentDmConversationsPageBodyState extends State !(store.getUser(id)?.isActive ?? true)); + if (hasDeactivatedUser) { + return SizedBox.shrink(); + } + } return RecentDmConversationsItem( narrow: narrow, - unreadCount: unreadsModel!.countInDmNarrow(narrow)); + unreadCount: unreadsModel!.countInDmNarrow(narrow), + onDmSelect: _handleDmSelect); })), Positioned( - bottom: 21, - child: _NewDmButton()), + bottom: bottomInsets + 21, + child: _NewDmButton(onDmSelect: _handleDmSelectForNewDms)), ]); } } @@ -92,10 +158,12 @@ class RecentDmConversationsItem extends StatelessWidget { super.key, required this.narrow, required this.unreadCount, + required this.onDmSelect, }); final DmNarrow narrow; final int unreadCount; + final OnDmSelectCallback onDmSelect; static const double _avatarSize = 32; @@ -138,10 +206,7 @@ class RecentDmConversationsItem extends StatelessWidget { return Material( color: backgroundColor, child: InkWell( - onTap: () { - Navigator.push(context, - MessageListPage.buildRoute(context: context, narrow: narrow)); - }, + onTap: () => onDmSelect(narrow), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ Padding(padding: const EdgeInsetsDirectional.fromSTEB(12, 8, 0, 8), @@ -175,7 +240,11 @@ class RecentDmConversationsItem extends StatelessWidget { } class _NewDmButton extends StatefulWidget { - const _NewDmButton(); + const _NewDmButton({ + required this.onDmSelect, + }); + + final OnDmSelectCallback onDmSelect; @override State<_NewDmButton> createState() => _NewDmButtonState(); @@ -197,7 +266,7 @@ class _NewDmButtonState extends State<_NewDmButton> { : designVariables.fabLabel; return GestureDetector( - onTap: () => showNewDmSheet(context), + onTap: () => showNewDmSheet(context, widget.onDmSelect), onTapDown: (_) => setState(() => _pressed = true), onTapUp: (_) => setState(() => _pressed = false), onTapCancel: () => setState(() => _pressed = false), diff --git a/lib/widgets/share.dart b/lib/widgets/share.dart new file mode 100644 index 0000000000..e71cc27d37 --- /dev/null +++ b/lib/widgets/share.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:mime/mime.dart'; + +import '../generated/l10n/zulip_localizations.dart'; +import '../host/android_intents.dart'; +import '../log.dart'; +import '../model/binding.dart'; +import '../model/narrow.dart'; +import 'app.dart'; +import 'color.dart'; +import 'compose_box.dart'; +import 'dialog.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'recent_dm_conversations.dart'; +import 'store.dart'; +import 'subscription_list.dart'; +import 'theme.dart'; + +// Responds to receiving shared content from other apps. +class ShareService { + const ShareService._(); + + static Future start() async { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + ZulipBinding.instance.androidIntentEvents.listen((event) { + switch (event) { + case AndroidIntentSendEvent(): + _handleSend(event); + } + }); + + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // Do nothing; we don't support receiving shared content from + // other apps on these platforms. + break; + } + } + + static Future _handleSend(AndroidIntentSendEvent intentSendEvent) async { + assert(defaultTargetPlatform == TargetPlatform.android); + + assert(debugLog('intentSendEvent.action: ${intentSendEvent.action}')); + assert(debugLog('intentSendEvent.extraText: ${intentSendEvent.extraText}')); + assert(debugLog('intentSendEvent.extraStream: [${intentSendEvent.extraStream?.join(',')}]')); + + NavigatorState navigator = await ZulipApp.navigator; + final context = navigator.context; + assert(context.mounted); + if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that + + final globalStore = GlobalStoreWidget.of(context); + + // TODO(#524) choose initial account as last one used + // TODO(#1779) allow selecting account, if there are multiple + final initialAccountId = globalStore.accounts.firstOrNull?.id; + + if (initialAccountId == null) { + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog( + context: context, + title: zulipLocalizations.errorSharingTitle, + message: zulipLocalizations.errorSharingAccountNotLoggedIn); + return; + } + + unawaited(navigator.push( + SharePage.buildRoute( + accountId: initialAccountId, + sharedFiles: intentSendEvent.extraStream?.map((sharedFile) { + var mimeType = sharedFile.mimeType; + + // Try to guess the mimeType from file header magic-number. + mimeType ??= lookupMimeType( + // Seems like the path shouldn't be required; we still want to look for + // matches on `headerBytes`. Thankfully we can still do that, by calling + // lookupMimeType with the empty string as the path. That's a value that + // doesn't map to any particular type, so the path will be effectively + // ignored, as desired. Upstream comment: + // https://github.com/dart-lang/mime/issues/11#issuecomment-2246824452 + '', + headerBytes: List.unmodifiable( + sharedFile.bytes.take(defaultMagicNumbersMaxLength))); + + return FileToUpload( + content: Stream.value(sharedFile.bytes), + length: sharedFile.bytes.length, + filename: sharedFile.name, + mimeType: mimeType); + }), + sharedText: intentSendEvent.extraText))); + } +} + +class SharePage extends StatelessWidget { + const SharePage({ + super.key, + required this.sharedFiles, + required this.sharedText, + }); + + final Iterable? sharedFiles; + final String? sharedText; + + static AccountRoute buildRoute({ + required int accountId, + required Iterable? sharedFiles, + required String? sharedText, + }) { + return MaterialAccountWidgetRoute( + accountId: accountId, + page: SharePage( + sharedFiles: sharedFiles, + sharedText: sharedText)); + } + + void _handleNarrowSelect(BuildContext context, Narrow narrow) { + final messageListPageStateKey = GlobalKey(); + + // Push the message list page, replacing the share page. + unawaited(Navigator.pushReplacement(context, + MessageListPage.buildRoute( + key: messageListPageStateKey, + context: context, + narrow: narrow))); + + // Wait for the message list page to accommodate in the widget tree from the route. + SchedulerBinding.instance.addPostFrameCallback((_) async { + final messageListPageState = messageListPageStateKey.currentState; + if (messageListPageState == null) return; // TODO(log) + final composeBoxState = messageListPageState.composeBoxState; + if (composeBoxState == null) return; // TODO(log) + + final composeBoxController = composeBoxState.controller; + + // Focus on the topic input if there is one, else focus on content + // input, if not already focused. + composeBoxController.requestFocusIfUnfocused(); + + // We can receive both: the file/s and an accompanying text, + // so first populate the compose box with the text, if there is any. + if (sharedText case var text?) { + if (!text.endsWith('\n')) text += '\n'; + + // If there are any shared files, add a separator new line. + if (sharedFiles != null) text += '\n'; + + // Populate the content input with this text. + final contentController = composeBoxController.content; + contentController.value = + contentController.value + .replaced(contentController.insertionIndex(), text); + } + // Then upload the files and populate the content input with their links. + if (sharedFiles != null) { + await composeBoxState.uploadFiles( + files: sharedFiles!, + // We handle requesting focus ourselves above. + shouldRequestFocus: false); + } + }); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: Text(zulipLocalizations.sharePageTitle), + bottom: TabBar( + indicatorColor: designVariables.icon, + labelColor: designVariables.foreground, + unselectedLabelColor: designVariables.foreground.withFadedAlpha(0.7), + splashFactory: NoSplash.splashFactory, + tabs: [ + Tab(text: zulipLocalizations.channelsPageTitle), + Tab(text: zulipLocalizations.recentDmConversationsPageTitle), + ])), + body: TabBarView(children: [ + SubscriptionListPageBody( + showTopicListButtonInActionSheet: false, + hideChannelsIfUserCantPost: true, + onChannelSelect: (narrow) => _handleNarrowSelect(context, narrow), + // TODO(#412) add onTopicSelect, Currently when user lands on the + // channel feed page from subscription list page and they tap + // on the topic recipient header, the user is brought to the + // topic message list, but without the share content. So, we + // might want to force the user to choose a topic or start a + // new topic from the subscription list page. + ), + RecentDmConversationsPageBody( + hideDmsIfUserCantPost: true, + onDmSelect: (narrow) => _handleNarrowSelect(context, narrow)), + ]))); + } +} diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index d64c578c5e..77fecb6c68 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -13,9 +13,31 @@ import 'text.dart'; import 'theme.dart'; import 'unread_count_badge.dart'; +typedef OnChannelSelectCallback = void Function(ChannelNarrow narrow); + /// Scrollable listing of subscribed streams. class SubscriptionListPageBody extends StatefulWidget { - const SubscriptionListPageBody({super.key}); + const SubscriptionListPageBody({ + super.key, + this.showTopicListButtonInActionSheet = true, + this.hideChannelsIfUserCantPost = false, + this.onChannelSelect, + }); + + // TODO refactor this widget to avoid reuse of the whole page, + // avoiding the need for these flags, callback(s), and the below + // handling of safe-area at this level of abstraction. + // See discussion: + // https://github.com/zulip/zulip-flutter/pull/1774#discussion_r2249032503 + final bool showTopicListButtonInActionSheet; + final bool hideChannelsIfUserCantPost; + + /// Callback to invoke when the user selects a channel from the list. + /// + /// If null, the default behavior is to navigate to the channel feed. + final OnChannelSelectCallback? onChannelSelect; + + // TODO(#412) add onTopicSelect @override State createState() => _SubscriptionListPageBodyState(); @@ -65,6 +87,12 @@ class _SubscriptionListPageBodyState extends State wit }); } + void _handleChannelSelect(ChannelNarrow narrow) { + Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: narrow)); + } + @override Widget build(BuildContext context) { // Design referenced from: @@ -86,6 +114,12 @@ class _SubscriptionListPageBodyState extends State wit final List pinned = []; final List unpinned = []; for (final subscription in store.subscriptions.values) { + if (widget.hideChannelsIfUserCantPost) { + if (!store.hasPostingPermission(inChannel: subscription, + user: store.selfUser, byDate: DateTime.now())) { + continue; + } + } if (subscription.pinToTop) { pinned.add(subscription); } else { @@ -101,19 +135,45 @@ class _SubscriptionListPageBodyState extends State wit message: zulipLocalizations.channelsEmptyPlaceholder); } - return SafeArea( // horizontal insets + final onChannelSelect = widget.onChannelSelect ?? _handleChannelSelect; + + return SafeArea( + // Don't pad the bottom here; we want the list content to do that. + // + // When this page is used in the context of the home page, this + // param and the below use of `SliverSafeArea` would be noop, because + // `Scaffold.bottomNavigationBar` in the home page handles that for us. + // But this page is also used for share-to-zulip page, so we need this + // to be handled here. + // + // Other *PageBody widgets don't handle this because they aren't + // (re-)used outside the context of the home page. + bottom: false, child: CustomScrollView( slivers: [ if (pinned.isNotEmpty) ...[ _SubscriptionListHeader(label: zulipLocalizations.pinnedSubscriptionsLabel), - _SubscriptionList(unreadsModel: unreadsModel, subscriptions: pinned), + _SubscriptionList( + unreadsModel: unreadsModel, + subscriptions: pinned, + showTopicListButtonInActionSheet: widget.showTopicListButtonInActionSheet, + onChannelSelect: onChannelSelect), ], if (unpinned.isNotEmpty) ...[ _SubscriptionListHeader(label: zulipLocalizations.unpinnedSubscriptionsLabel), - _SubscriptionList(unreadsModel: unreadsModel, subscriptions: unpinned), + _SubscriptionList( + unreadsModel: unreadsModel, + subscriptions: unpinned, + showTopicListButtonInActionSheet: widget.showTopicListButtonInActionSheet, + onChannelSelect: onChannelSelect), ], // TODO(#188): add button leading to "All Streams" page with ability to subscribe + + // This ensures last item in scrollable can settle in an unobstructed area. + // (Noop in the home-page case; see comment on `bottom: false` arg in + // use of `SafeArea` above.) + const SliverSafeArea(sliver: SliverToBoxAdapter(child: SizedBox.shrink())), ])); } } @@ -160,10 +220,14 @@ class _SubscriptionList extends StatelessWidget { const _SubscriptionList({ required this.unreadsModel, required this.subscriptions, + required this.showTopicListButtonInActionSheet, + required this.onChannelSelect, }); final Unreads? unreadsModel; final List subscriptions; + final bool showTopicListButtonInActionSheet; + final OnChannelSelectCallback onChannelSelect; @override Widget build(BuildContext context) { @@ -176,7 +240,9 @@ class _SubscriptionList extends StatelessWidget { && unreadsModel!.countInChannelNarrow(subscription.streamId) > 0; return SubscriptionItem(subscription: subscription, unreadCount: unreadCount, - showMutedUnreadBadge: showMutedUnreadBadge); + showMutedUnreadBadge: showMutedUnreadBadge, + showTopicListButtonInActionSheet: showTopicListButtonInActionSheet, + onChannelSelect: onChannelSelect); }); } } @@ -188,11 +254,15 @@ class SubscriptionItem extends StatelessWidget { required this.subscription, required this.unreadCount, required this.showMutedUnreadBadge, + required this.showTopicListButtonInActionSheet, + required this.onChannelSelect, }); final Subscription subscription; final int unreadCount; final bool showMutedUnreadBadge; + final bool showTopicListButtonInActionSheet; + final OnChannelSelectCallback onChannelSelect; @override Widget build(BuildContext context) { @@ -205,12 +275,10 @@ class SubscriptionItem extends StatelessWidget { // TODO(design) check if this is the right variable color: designVariables.background, child: InkWell( - onTap: () { - Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: ChannelNarrow(subscription.streamId))); - }, - onLongPress: () => showChannelActionSheet(context, channelId: subscription.streamId), + onTap: () => onChannelSelect(ChannelNarrow(subscription.streamId)), + onLongPress: () => showChannelActionSheet(context, + channelId: subscription.streamId, + showTopicListButton: showTopicListButtonInActionSheet), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(width: 16), Padding( diff --git a/pigeon/android_intents.dart b/pigeon/android_intents.dart new file mode 100644 index 0000000000..2d668da518 --- /dev/null +++ b/pigeon/android_intents.dart @@ -0,0 +1,50 @@ +import 'package:pigeon/pigeon.dart'; + +// To rebuild this pigeon's output after editing this file, +// run `tools/check pigeon --fix`. +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/host/android_intents.g.dart', + kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt', + kotlinOptions: KotlinOptions( + package: 'com.zulip.flutter', + // One error class is already generated in AndroidNotifications.g.kt , + // so avoid generating another one, preventing duplicate classes under + // the same namespace. + includeErrorClass: false))) + +// TODO separate out API calls for resolving file name, getting mimetype, getting bytes? +class IntentSharedFile { + const IntentSharedFile({ + required this.name, + required this.mimeType, + required this.bytes, + }); + + final String name; + final String? mimeType; + final Uint8List bytes; +} + +sealed class AndroidIntentEvent { + const AndroidIntentEvent(); + + // Pigeon doesn't seem to allow fields in sealed classes. + // final String action; +} + +class AndroidIntentSendEvent extends AndroidIntentEvent { + const AndroidIntentSendEvent({ + required this.action, // 'android.intent.action.SEND' or 'android.intent.action.SEND_MULTIPLE' + required this.extraText, + required this.extraStream, + }); + + final String action; + final String? extraText; + final List? extraStream; +} + +@EventChannelApi() +abstract class AndroidIntentsEventChannelApi { + AndroidIntentEvent androidIntentEvents(); +} diff --git a/test/model/binding.dart b/test/model/binding.dart index ca9f43603f..c7fe2bf639 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:test/fake.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; +import 'package:zulip/host/android_intents.dart'; import 'package:zulip/host/android_notifications.dart'; import 'package:zulip/host/notifications.dart'; import 'package:zulip/model/binding.dart'; @@ -419,6 +420,10 @@ class TestZulipBinding extends ZulipBinding { Future toggleWakelock({required bool enable}) async { _wakelockEnabled = enable; } + + @override + // TODO(#1787) implement androidIntentEvents and write related tests + Stream get androidIntentEvents => throw UnimplementedError(); } class FakeFirebaseMessaging extends Fake implements FirebaseMessaging {