diff --git a/LIBCWTCH-GO.version b/LIBCWTCH-GO.version index 5e60f32c..fc6ed486 100644 --- a/LIBCWTCH-GO.version +++ b/LIBCWTCH-GO.version @@ -1 +1 @@ -v1.2.1-2-ga8e7bba-2021-09-14-21-04 +v1.3.0-2021-09-30-20-24 \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 58459a0b..5ac09ca3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdkVersion 29 + compileSdkVersion 30 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -48,7 +48,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "im.cwtch.flwtch" minSdkVersion 16 - targetSdkVersion 29 + targetSdkVersion 30 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ec51df3e..72121fd4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,8 @@ android:name="io.flutter.app.FlutterApplication" android:label="Cwtch" android:extractNativeLibs="true" - android:icon="@mipmap/knott"> + android:icon="@mipmap/knott" + android:requestLegacyExternalStorage="true"> + + + + + + diff --git a/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt b/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt index 5a71ea8b..d18bb0d9 100644 --- a/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt +++ b/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt @@ -15,6 +15,10 @@ import cwtch.Cwtch import io.flutter.FlutterInjector import org.json.JSONObject +import java.nio.file.Files +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import android.net.Uri class FlwtchWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) { @@ -56,6 +60,7 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) : if (Cwtch.startCwtch(appDir, torPath) != 0.toLong()) return Result.failure() Log.i("FlwtchWorker.kt", "startCwtch success, starting coroutine AppbusEvent loop...") + val downloadIDs = mutableMapOf() while(true) { val evt = MainActivity.AppbusEvent(Cwtch.getAppBusEvent()) if (evt.EventType == "NewMessageFromPeer" || evt.EventType == "NewMessageFromGroup") { @@ -93,6 +98,63 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) : .build() notificationManager.notify(getNotificationID(data.getString("ProfileOnion"), handle), newNotification) } + } else if (evt.EventType == "FileDownloadProgressUpdate") { + try { + val data = JSONObject(evt.Data); + val fileKey = data.getString("FileKey"); + val title = data.getString("NameSuggestion"); + val progress = data.getString("Progress").toInt(); + val progressMax = data.getString("FileSizeInChunks").toInt(); + if (!downloadIDs.containsKey(fileKey)) { + downloadIDs.put(fileKey, downloadIDs.count()); + } + var dlID = downloadIDs.get(fileKey); + if (dlID == null) { + dlID = 0; + } + val channelId = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createDownloadNotificationChannel(fileKey, fileKey) + } else { + // If earlier version channel ID is not used + // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context) + "" + }; + val newNotification = NotificationCompat.Builder(applicationContext, channelId) + .setOngoing(true) + .setContentTitle("Downloading")//todo: translate + .setContentText(title) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setProgress(progressMax, progress, false) + .setSound(null) + //.setSilent(true) + .build(); + notificationManager.notify(dlID, newNotification); + } catch (e: Exception) { + Log.i("FlwtchWorker->FileDownloadProgressUpdate", e.toString() + " :: " + e.getStackTrace()); + } + } else if (evt.EventType == "FileDownloaded") { + Log.i("FlwtchWorker", "file downloaded!"); + val data = JSONObject(evt.Data); + val tempFile = data.getString("TempFile"); + val fileKey = data.getString("FileKey"); + if (tempFile != "") { + val filePath = data.getString("FilePath"); + Log.i("FlwtchWorker", "moving "+tempFile+" to "+filePath); + val sourcePath = Paths.get(tempFile); + val targetUri = Uri.parse(filePath); + val os = this.applicationContext.getContentResolver().openOutputStream(targetUri); + val bytesWritten = Files.copy(sourcePath, os); + Log.i("FlwtchWorker", "copied " + bytesWritten.toString() + " bytes"); + if (bytesWritten != 0L) { + os?.flush(); + os?.close(); + Files.delete(sourcePath); + } + } + if (downloadIDs.containsKey(fileKey)) { + notificationManager.cancel(downloadIDs.get(fileKey)?:0); + } } Intent().also { intent -> @@ -157,6 +219,26 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) : val target = (a.get("target") as? String) ?: "" Cwtch.sendInvitation(profile, handle, target) } + "ShareFile" -> { + val profile = (a.get("ProfileOnion") as? String) ?: "" + val handle = (a.get("handle") as? String) ?: "" + val filepath = (a.get("filepath") as? String) ?: "" + Cwtch.shareFile(profile, handle, filepath) + } + "DownloadFile" -> { + val profile = (a.get("ProfileOnion") as? String) ?: "" + val handle = (a.get("handle") as? String) ?: "" + val filepath = (a.get("filepath") as? String) ?: "" + val manifestpath = (a.get("manifestpath") as? String) ?: "" + val filekey = (a.get("filekey") as? String) ?: "" + Log.i("FlwtchWorker::DownloadFile", "DownloadFile("+filepath+", "+manifestpath+")") + Cwtch.downloadFile(profile, handle, filepath, manifestpath, filekey) + } + "CheckDownloadStatus" -> { + val profile = (a.get("ProfileOnion") as? String) ?: "" + val fileKey = (a.get("fileKey") as? String) ?: "" + Cwtch.checkDownloadStatus(profile, fileKey) + } "SendProfileEvent" -> { val onion = (a.get("onion") as? String) ?: "" val jsonEvent = (a.get("jsonEvent") as? String) ?: "" @@ -268,6 +350,15 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) : return channelId } + @RequiresApi(Build.VERSION_CODES.O) + private fun createDownloadNotificationChannel(channelId: String, channelName: String): String{ + val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW) + chan.lightColor = Color.MAGENTA + chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE + notificationManager.createNotificationChannel(chan) + return channelId + } + companion object { const val KEY_METHOD = "KEY_METHOD" const val KEY_ARGS = "KEY_ARGS" diff --git a/android/app/src/main/kotlin/im/cwtch/flwtch/MainActivity.kt b/android/app/src/main/kotlin/im/cwtch/flwtch/MainActivity.kt index 4025131f..5ae7408b 100644 --- a/android/app/src/main/kotlin/im/cwtch/flwtch/MainActivity.kt +++ b/android/app/src/main/kotlin/im/cwtch/flwtch/MainActivity.kt @@ -12,17 +12,25 @@ import android.view.Window import androidx.lifecycle.Observer import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.work.* - import io.flutter.embedding.android.SplashScreen import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.ErrorLogResult import org.json.JSONObject import java.util.concurrent.TimeUnit +import android.net.Uri +import android.provider.DocumentsContract +import android.content.ContentUris +import android.os.Build +import android.os.Environment +import android.database.Cursor +import android.provider.MediaStore + class MainActivity: FlutterActivity() { override fun provideSplashScreen(): SplashScreen? = SplashView() @@ -47,6 +55,12 @@ class MainActivity: FlutterActivity() { private var notificationClickChannel: MethodChannel? = null private var shutdownClickChannel: MethodChannel? = null + // "Download to..." prompt extra arguments + private var dlToProfile = "" + private var dlToHandle = "" + private var dlToFileKey = "" + + // handles clicks received from outside the app (ie, notifications) override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) if (notificationClickChannel == null || intent.extras == null) return @@ -68,6 +82,24 @@ class MainActivity: FlutterActivity() { } } + // handles return values from the system file picker + override fun onActivityResult(requestCode: Int, result: Int, intent: Intent?) { + if (intent == null || intent!!.getData() == null) { + Log.i("MainActivity:onActivityResult", "user canceled activity"); + return; + } + + val filePath = intent!!.getData().toString(); + val manifestPath = StringBuilder().append(this.applicationContext.cacheDir).append("/").append(this.dlToFileKey).toString(); + handleCwtch(MethodCall("DownloadFile", mapOf( + "ProfileOnion" to this.dlToProfile, + "handle" to this.dlToHandle, + "filepath" to filePath, + "manifestpath" to manifestPath, + "filekey" to this.dlToFileKey + )), ErrorLogResult(""));//placeholder; this Result is never actually invoked + } + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) // Note: this methods are invoked on the main thread. @@ -125,6 +157,18 @@ class MainActivity: FlutterActivity() { val workRequest = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).setInputData(data).addTag(WORKER_TAG).addTag(uniqueTag).build() WorkManager.getInstance(this).enqueueUniquePeriodicWork("req_$uniqueTag", ExistingPeriodicWorkPolicy.REPLACE, workRequest) return + } else if (call.method == "CreateDownloadableFile") { + this.dlToProfile = argmap["ProfileOnion"] ?: "" + this.dlToHandle = argmap["handle"] ?: "" + val suggestedName = argmap["filename"] ?: "filename.ext" + this.dlToFileKey = argmap["filekey"] ?: "" + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/octet-stream" + putExtra(Intent.EXTRA_TITLE, suggestedName) + } + startActivityForResult(intent, 1) + return } // ...otherwise fallthru to a normal ffi method call (and return the result using the result callback) @@ -178,20 +222,6 @@ class MainActivity: FlutterActivity() { WorkManager.getInstance(this).pruneWork() } -// source: https://web.archive.org/web/20210203022531/https://stackoverflow.com/questions/41928803/how-to-parse-json-in-kotlin/50468095 -// for reference: -// -// class Response(json: String) : JSONObject(json) { -// val type: String? = this.optString("type") -// val data = this.optJSONArray("data") -// ?.let { 0.until(it.length()).map { i -> it.optJSONObject(i) } } // returns an array of JSONObject -// ?.map { Foo(it.toString()) } // transforms each JSONObject of the array into Foo -// } -// -// class Foo(json: String) : JSONObject(json) { -// val id = this.optInt("id") -// val title: String? = this.optString("title") -// } class AppbusEvent(json: String) : JSONObject(json) { val EventType = this.optString("EventType") val EventID = this.optString("EventID") diff --git a/android/build.gradle b/android/build.gradle index c887697b..5a12dade 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,7 +6,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' + classpath 'com.android.tools.build:gradle:3.5.4' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } diff --git a/lib/cwtch/cwtch.dart b/lib/cwtch/cwtch.dart index 22868c44..0df9c0b0 100644 --- a/lib/cwtch/cwtch.dart +++ b/lib/cwtch/cwtch.dart @@ -40,6 +40,15 @@ abstract class Cwtch { // ignore: non_constant_identifier_names void SendInvitation(String profile, String handle, String target); + // ignore: non_constant_identifier_names + void ShareFile(String profile, String handle, String filepath); + // ignore: non_constant_identifier_names + void DownloadFile(String profile, String handle, String filepath, String manifestpath, String filekey); + // ignore: non_constant_identifier_names + void CreateDownloadableFile(String profile, String handle, String filenameSuggestion, String filekey); + // ignore: non_constant_identifier_names + void CheckDownloadStatus(String profile, String fileKey); + // ignore: non_constant_identifier_names void ArchiveConversation(String profile, String handle); // ignore: non_constant_identifier_names diff --git a/lib/cwtch/cwtchNotifier.dart b/lib/cwtch/cwtchNotifier.dart index 96c7e547..90071154 100644 --- a/lib/cwtch/cwtchNotifier.dart +++ b/lib/cwtch/cwtchNotifier.dart @@ -182,8 +182,10 @@ class CwtchNotifier { if (contactHandle == null || contactHandle == "") contactHandle = data["GroupID"]; profileCN.getProfile(data["Identity"])?.contactList.getContact(contactHandle)!.totalMessages = int.parse(data["Data"]); break; + case "SendMessageToPeerError": + // Ignore + break; case "IndexedFailure": - EnvironmentConfig.debugLog("IndexedFailure"); var idx = data["Index"]; var key = profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(data["RemotePeer"])?.getMessageKey(idx); try { @@ -323,6 +325,15 @@ class CwtchNotifier { EnvironmentConfig.debugLog("unhandled peer attribute event: ${data['Path']}"); } break; + case "ManifestSaved": + profileCN.getProfile(data["ProfileOnion"])?.downloadMarkManifest(data["FileKey"]); + break; + case "FileDownloadProgressUpdate": + profileCN.getProfile(data["ProfileOnion"])?.downloadUpdate(data["FileKey"], int.parse(data["Progress"])); + break; + case "FileDownloaded": + profileCN.getProfile(data["ProfileOnion"])?.downloadMarkFinished(data["FileKey"], data["FilePath"]); + break; default: EnvironmentConfig.debugLog("unhandled event: $type"); } diff --git a/lib/cwtch/ffi.dart b/lib/cwtch/ffi.dart index 4bbee290..6b7cb552 100644 --- a/lib/cwtch/ffi.dart +++ b/lib/cwtch/ffi.dart @@ -36,6 +36,9 @@ typedef VoidFromStringStringStringFn = void Function(Pointer, int, Pointer typedef void_from_string_string_string_string_function = Void Function(Pointer, Int32, Pointer, Int32, Pointer, Int32, Pointer, Int32); typedef VoidFromStringStringStringStringFn = void Function(Pointer, int, Pointer, int, Pointer, int, Pointer, int); +typedef void_from_string_string_string_string_string_function = Void Function(Pointer, Int32, Pointer, Int32, Pointer, Int32, Pointer, Int32, Pointer, Int32); +typedef VoidFromStringStringStringStringStringFn = void Function(Pointer, int, Pointer, int, Pointer, int, Pointer, int, Pointer, int); + typedef void_from_string_string_int_int_function = Void Function(Pointer, Int32, Pointer, Int32, Int64, Int64); typedef VoidFromStringStringIntIntFn = void Function(Pointer, int, Pointer, int, int, int); @@ -354,6 +357,59 @@ class CwtchFfi implements Cwtch { malloc.free(u3); } + @override + // ignore: non_constant_identifier_names + void ShareFile(String profileOnion, String contactHandle, String filepath) { + var shareFile = library.lookup>("c_ShareFile"); + // ignore: non_constant_identifier_names + final ShareFile = shareFile.asFunction(); + final u1 = profileOnion.toNativeUtf8(); + final u2 = contactHandle.toNativeUtf8(); + final u3 = filepath.toNativeUtf8(); + ShareFile(u1, u1.length, u2, u2.length, u3, u3.length); + malloc.free(u1); + malloc.free(u2); + malloc.free(u3); + } + + @override + // ignore: non_constant_identifier_names + void DownloadFile(String profileOnion, String contactHandle, String filepath, String manifestpath, String filekey) { + var dlFile = library.lookup>("c_DownloadFile"); + // ignore: non_constant_identifier_names + final DownloadFile = dlFile.asFunction(); + final u1 = profileOnion.toNativeUtf8(); + final u2 = contactHandle.toNativeUtf8(); + final u3 = filepath.toNativeUtf8(); + final u4 = manifestpath.toNativeUtf8(); + final u5 = filekey.toNativeUtf8(); + DownloadFile(u1, u1.length, u2, u2.length, u3, u3.length, u4, u4.length, u5, u5.length); + malloc.free(u1); + malloc.free(u2); + malloc.free(u3); + malloc.free(u4); + malloc.free(u5); + } + + @override + // ignore: non_constant_identifier_names + void CreateDownloadableFile(String profileOnion, String contactHandle, String filenameSuggestion, String filekey) { + // android only - do nothing + } + + @override + // ignore: non_constant_identifier_names + void CheckDownloadStatus(String profileOnion, String fileKey) { + var checkDownloadStatus = library.lookup>("c_CheckDownloadStatus"); + // ignore: non_constant_identifier_names + final CheckDownloadStatus = checkDownloadStatus.asFunction(); + final u1 = profileOnion.toNativeUtf8(); + final u2 = fileKey.toNativeUtf8(); + CheckDownloadStatus(u1, u1.length, u2, u2.length); + malloc.free(u1); + malloc.free(u2); + } + @override // ignore: non_constant_identifier_names void ResetTor() { diff --git a/lib/cwtch/gomobile.dart b/lib/cwtch/gomobile.dart index 1ca6b1d2..bf73813e 100644 --- a/lib/cwtch/gomobile.dart +++ b/lib/cwtch/gomobile.dart @@ -131,6 +131,29 @@ class CwtchGomobile implements Cwtch { cwtchPlatform.invokeMethod("SendInvitation", {"ProfileOnion": profileOnion, "handle": contactHandle, "target": target}); } + @override + // ignore: non_constant_identifier_names + void ShareFile(String profileOnion, String contactHandle, String filepath) { + cwtchPlatform.invokeMethod("ShareFile", {"ProfileOnion": profileOnion, "handle": contactHandle, "filepath": filepath}); + } + + @override + // ignore: non_constant_identifier_names + void DownloadFile(String profileOnion, String contactHandle, String filepath, String manifestpath, String filekey) { + cwtchPlatform.invokeMethod("DownloadFile", {"ProfileOnion": profileOnion, "handle": contactHandle, "filepath": filepath, "manifestpath": manifestpath, "filekey": filekey}); + } + + // ignore: non_constant_identifier_names + void CreateDownloadableFile(String profileOnion, String contactHandle, String filenameSuggestion, String filekey) { + cwtchPlatform.invokeMethod("CreateDownloadableFile", {"ProfileOnion": profileOnion, "handle": contactHandle, "filename": filenameSuggestion, "filekey": filekey}); + } + + @override + // ignore: non_constant_identifier_names + void CheckDownloadStatus(String profileOnion, String fileKey) { + cwtchPlatform.invokeMethod("CheckDownloadStatus", {"ProfileOnion": profileOnion, "fileKey": fileKey}); + } + @override // ignore: non_constant_identifier_names void ResetTor() { @@ -175,7 +198,7 @@ class CwtchGomobile implements Cwtch { @override void UpdateMessageFlags(String profile, String handle, int index, int flags) { print("gomobile.dart UpdateMessageFlags " + index.toString()); - cwtchPlatform.invokeMethod("UpdateMessageFlags", {"profile": profile, "contact": handle, "index": index, "flags": flags}); + cwtchPlatform.invokeMethod("UpdateMessageFlags", {"profile": profile, "contact": handle, "midx": index, "flags": flags}); } @override diff --git a/lib/model.dart b/lib/model.dart index d63fe3c3..f036be0f 100644 --- a/lib/model.dart +++ b/lib/model.dart @@ -213,6 +213,7 @@ class ProfileInfoState extends ChangeNotifier { String _imagePath = ""; int _unreadMessages = 0; bool _online = false; + Map _downloads = Map(); // assume profiles are encrypted...this will be set to false // in the constructor if the profile is encrypted with the defacto password. @@ -356,6 +357,100 @@ class ProfileInfoState extends ChangeNotifier { }); } } + + void downloadInit(String fileKey, int numChunks) { + this._downloads[fileKey] = FileDownloadProgress(numChunks, DateTime.now()); + } + + void downloadUpdate(String fileKey, int progress) { + if (!downloadActive(fileKey)) { + print("error: received progress for unknown download " + fileKey); + } else { + this._downloads[fileKey]!.chunksDownloaded = progress; + notifyListeners(); + } + } + + void downloadMarkManifest(String fileKey) { + if (!downloadActive(fileKey)) { + print("error: received download completion notice for unknown download " + fileKey); + } else { + this._downloads[fileKey]!.gotManifest = true; + notifyListeners(); + } + } + + void downloadMarkFinished(String fileKey, String finalPath) { + if (!downloadActive(fileKey)) { + // happens as a result of a CheckDownloadStatus call, + // invoked from a historical (timeline) download message + // so setting numChunks correctly shouldn't matter + this.downloadInit(fileKey, 1); + } + this._downloads[fileKey]!.timeEnd = DateTime.now(); + this._downloads[fileKey]!.downloadedTo = finalPath; + this._downloads[fileKey]!.complete = true; + notifyListeners(); + } + + bool downloadActive(String fileKey) { + return this._downloads.containsKey(fileKey); + } + + bool downloadGotManifest(String fileKey) { + return this._downloads.containsKey(fileKey) && this._downloads[fileKey]!.gotManifest; + } + + bool downloadComplete(String fileKey) { + return this._downloads.containsKey(fileKey) && this._downloads[fileKey]!.complete; + } + + double downloadProgress(String fileKey) { + return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.progress() : 0.0; + } + + String? downloadFinalPath(String fileKey) { + return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.downloadedTo : null; + } + + String downloadSpeed(String fileKey) { + if (!downloadActive(fileKey) || this._downloads[fileKey]!.chunksDownloaded == 0) { + return "0 B/s"; + } + var bytes = this._downloads[fileKey]!.chunksDownloaded * 4096; + var seconds = (this._downloads[fileKey]!.timeEnd ?? DateTime.now()).difference(this._downloads[fileKey]!.timeStart!).inSeconds; + if (seconds == 0) { + return "0 B/s"; + } + return prettyBytes((bytes / seconds).round()) + "/s"; + } +} + +class FileDownloadProgress { + int chunksDownloaded = 0; + int chunksTotal = 1; + bool complete = false; + bool gotManifest = false; + String? downloadedTo; + DateTime? timeStart; + DateTime? timeEnd; + + FileDownloadProgress(this.chunksTotal, this.timeStart); + double progress() { + return 1.0 * chunksDownloaded / chunksTotal; + } +} + +String prettyBytes(int bytes) { + if (bytes > 1000000000) { + return (1.0 * bytes / 1000000000).toStringAsFixed(1) + " GB"; + } else if (bytes > 1000000) { + return (1.0 * bytes / 1000000).toStringAsFixed(1) + " MB"; + } else if (bytes > 1000) { + return (1.0 * bytes / 1000).toStringAsFixed(1) + " kB"; + } else { + return bytes.toString() + " B"; + } } enum ContactAuthorization { unknown, approved, blocked } diff --git a/lib/models/message.dart b/lib/models/message.dart index b93ce649..2156b8d6 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../main.dart'; import '../model.dart'; +import 'messages/filemessage.dart'; import 'messages/invitemessage.dart'; import 'messages/malformedmessage.dart'; import 'messages/quotedmessage.dart'; @@ -14,6 +15,7 @@ const TextMessageOverlay = 1; const QuotedMessageOverlay = 10; const SuggestContactOverlay = 100; const InviteGroupOverlay = 101; +const FileShareOverlay = 200; // Defines the length of the tor v3 onion address. Code using this constant will // need to updated when we allow multiple different identifiers. At which time @@ -33,38 +35,39 @@ Future messageHandler(BuildContext context, String profileOnion, String try { var rawMessageEnvelopeFuture = Provider.of(context, listen: false).cwtch.GetMessage(profileOnion, contactHandle, index); return rawMessageEnvelopeFuture.then((dynamic rawMessageEnvelope) { - dynamic messageWrapper = jsonDecode(rawMessageEnvelope); - // There are 2 conditions in which this error condition can be met: - // 1. The application == nil, in which case this instance of the UI is already - // broken beyond repair, and will either be replaced by a new version, or requires a complete - // restart. - // 2. This index was incremented and we happened to fetch the timeline prior to the messages inclusion. - // This should be rare as Timeline addition/fetching is mutex protected and Dart itself will pipeline the - // calls to libCwtch-go - however because we use goroutines on the backend there is always a chance that one - // will find itself delayed. - // The second case is recoverable by tail-recursing this future. - if (messageWrapper['Message'] == null || messageWrapper['Message'] == '' || messageWrapper['Message'] == '{}') { - return Future.delayed(Duration(seconds: 2), () { - print("Tail recursive call to messageHandler called. This should be a rare event. If you see multiples of this log over a short period of time please log it as a bug."); - return messageHandler(context, profileOnion, contactHandle, index).then((value) => value); - }); - } - - // Construct the initial metadata - var timestamp = DateTime.tryParse(messageWrapper['Timestamp'])!; - var senderHandle = messageWrapper['PeerID']; - var senderImage = messageWrapper['ContactImage']; - var flags = int.parse(messageWrapper['Flags'].toString(), radix: 2); - var ackd = messageWrapper['Acknowledged']; - var error = messageWrapper['Error'] != null; - String? signature; - // If this is a group, store the signature - if (contactHandle.length == GroupConversationHandleLength) { - signature = messageWrapper['Signature']; - } - var metadata = MessageMetadata(profileOnion, contactHandle, index, timestamp, senderHandle, senderImage, signature, flags, ackd, error); - + var metadata = MessageMetadata(profileOnion, contactHandle, index, DateTime.now(), "", "", null, 0, false, true); try { + dynamic messageWrapper = jsonDecode(rawMessageEnvelope); + // There are 2 conditions in which this error condition can be met: + // 1. The application == nil, in which case this instance of the UI is already + // broken beyond repair, and will either be replaced by a new version, or requires a complete + // restart. + // 2. This index was incremented and we happened to fetch the timeline prior to the messages inclusion. + // This should be rare as Timeline addition/fetching is mutex protected and Dart itself will pipeline the + // calls to libCwtch-go - however because we use goroutines on the backend there is always a chance that one + // will find itself delayed. + // The second case is recoverable by tail-recursing this future. + if (messageWrapper['Message'] == null || messageWrapper['Message'] == '' || messageWrapper['Message'] == '{}') { + return Future.delayed(Duration(seconds: 2), () { + print("Tail recursive call to messageHandler called. This should be a rare event. If you see multiples of this log over a short period of time please log it as a bug."); + return messageHandler(context, profileOnion, contactHandle, index).then((value) => value); + }); + } + + // Construct the initial metadata + var timestamp = DateTime.tryParse(messageWrapper['Timestamp'])!; + var senderHandle = messageWrapper['PeerID']; + var senderImage = messageWrapper['ContactImage']; + var flags = int.parse(messageWrapper['Flags'].toString()); + var ackd = messageWrapper['Acknowledged']; + var error = messageWrapper['Error'] != null; + String? signature; + // If this is a group, store the signature + if (contactHandle.length == GroupConversationHandleLength) { + signature = messageWrapper['Signature']; + } + metadata = MessageMetadata(profileOnion, contactHandle, index, timestamp, senderHandle, senderImage, signature, flags, ackd, error); + dynamic message = jsonDecode(messageWrapper['Message']); var content = message['d'] as dynamic; var overlay = int.parse(message['o'].toString()); @@ -77,11 +80,14 @@ Future messageHandler(BuildContext context, String profileOnion, String return InviteMessage(overlay, metadata, content); case QuotedMessageOverlay: return QuotedMessage(metadata, content); + case FileShareOverlay: + return FileMessage(metadata, content); default: // Metadata is valid, content is not.. return MalformedMessage(metadata); } } catch (e) { + print("an error! " + e.toString()); return MalformedMessage(metadata); } }); diff --git a/lib/models/messages/filemessage.dart b/lib/models/messages/filemessage.dart new file mode 100644 index 00000000..fa178dd4 --- /dev/null +++ b/lib/models/messages/filemessage.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:cwtch/models/message.dart'; +import 'package:cwtch/widgets/filebubble.dart'; +import 'package:cwtch/widgets/malformedbubble.dart'; +import 'package:cwtch/widgets/messagerow.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import '../../model.dart'; + +class FileMessage extends Message { + final MessageMetadata metadata; + final String content; + + FileMessage(this.metadata, this.content); + + @override + Widget getWidget(BuildContext context) { + return ChangeNotifierProvider.value( + value: this.metadata, + builder: (bcontext, child) { + String idx = Provider.of(context).isGroup == true && this.metadata.signature != null ? this.metadata.signature! : this.metadata.messageIndex.toString(); + dynamic shareObj = jsonDecode(this.content); + if (shareObj == null) { + return MessageRow(MalformedBubble()); + } + String nameSuggestion = shareObj['f'] as String; + String rootHash = shareObj['h'] as String; + String nonce = shareObj['n'] as String; + int fileSize = shareObj['s'] as int; + + return MessageRow(FileBubble(nameSuggestion, rootHash, nonce, fileSize), key: Provider.of(bcontext).getMessageKey(idx)); + }); + } + + @override + Widget getPreviewWidget(BuildContext context) { + return ChangeNotifierProvider.value( + value: this.metadata, + builder: (bcontext, child) { + dynamic shareObj = jsonDecode(this.content); + if (shareObj == null) { + return MessageRow(MalformedBubble()); + } + String nameSuggestion = shareObj['n'] as String; + String rootHash = shareObj['h'] as String; + String nonce = shareObj['n'] as String; + int fileSize = shareObj['s'] as int; + return FileBubble( + nameSuggestion, + rootHash, + nonce, + fileSize, + interactive: false, + ); + }); + } + + @override + MessageMetadata getMetadata() { + return this.metadata; + } +} diff --git a/lib/settings.dart b/lib/settings.dart index fa2d965a..c0955489 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -9,6 +9,7 @@ import 'opaque.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; const TapirGroupsExperiment = "tapir-groups-experiment"; +const FileSharingExperiment = "filesharing"; enum DualpaneMode { Single, diff --git a/lib/views/globalsettingsview.dart b/lib/views/globalsettingsview.dart index 2758c1f1..20695220 100644 --- a/lib/views/globalsettingsview.dart +++ b/lib/views/globalsettingsview.dart @@ -188,6 +188,22 @@ class _GlobalSettingsViewState extends State { inactiveTrackColor: settings.theme.defaultButtonDisabledColor(), secondary: Icon(CwtchIcons.enable_groups, color: settings.current().mainTextColor()), ), + SwitchListTile( + title: Text(AppLocalizations.of(context)!.settingFileSharing, style: TextStyle(color: settings.current().mainTextColor())), + subtitle: Text(AppLocalizations.of(context)!.descriptionFileSharing), + value: settings.isExperimentEnabled(FileSharingExperiment), + onChanged: (bool value) { + if (value) { + settings.enableExperiment(FileSharingExperiment); + } else { + settings.disableExperiment(FileSharingExperiment); + } + saveSettings(context); + }, + activeTrackColor: settings.theme.defaultButtonActiveColor(), + inactiveTrackColor: settings.theme.defaultButtonDisabledColor(), + secondary: Icon(Icons.attach_file, color: settings.current().mainTextColor()), + ), ], )), AboutListTile( diff --git a/lib/views/messageview.dart b/lib/views/messageview.dart index f550287d..d279db17 100644 --- a/lib/views/messageview.dart +++ b/lib/views/messageview.dart @@ -6,6 +6,9 @@ import 'package:cwtch/models/message.dart'; import 'package:cwtch/models/messages/quotedmessage.dart'; import 'package:cwtch/widgets/messageloadingbubble.dart'; import 'package:cwtch/widgets/profileimage.dart'; + +import 'package:file_picker/file_picker.dart'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:cwtch/views/peersettingsview.dart'; @@ -14,6 +17,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:path/path.dart' show basename; import '../main.dart'; import '../model.dart'; @@ -36,6 +40,9 @@ class _MessageViewState extends State { @override void initState() { scrollListener.itemPositions.addListener(() { + if (scrollListener.itemPositions.value.length == 0) { + return; + } var first = scrollListener.itemPositions.value.first.index; var last = scrollListener.itemPositions.value.last.index; // sometimes these go hi->lo and sometimes they go lo->hi because [who tf knows] @@ -74,6 +81,25 @@ class _MessageViewState extends State { return Card(child: Center(child: Text(AppLocalizations.of(context)!.addContactFirst))); } + var appBarButtons = []; + if (Provider.of(context).isOnline()) { + appBarButtons.add(IconButton( + icon: Icon(Icons.attach_file, size: 24), + tooltip: AppLocalizations.of(context)!.tooltipSendFile, + onPressed: _showFilePicker, + )); + appBarButtons.add(IconButton( + icon: Icon(CwtchIcons.send_invite, size: 24), + tooltip: AppLocalizations.of(context)!.sendInvite, + onPressed: () { + _modalSendInvitation(context); + })); + } + appBarButtons.add(IconButton( + icon: Provider.of(context, listen: false).isGroup == true ? Icon(CwtchIcons.group_settings_24px) : Icon(CwtchIcons.peer_settings_24px), + tooltip: AppLocalizations.of(context)!.conversationSettings, + onPressed: _pushContactSettings)); + var appState = Provider.of(context); return WillPopScope( onWillPop: _onWillPop, @@ -105,21 +131,7 @@ class _MessageViewState extends State { overflow: TextOverflow.ellipsis, )) ]), - actions: [ - //IconButton(icon: Icon(Icons.chat), onPressed: _pushContactSettings), - //IconButton(icon: Icon(Icons.list), onPressed: _pushContactSettings), - //IconButton(icon: Icon(Icons.push_pin), onPressed: _pushContactSettings), - IconButton( - icon: Icon(CwtchIcons.send_invite, size: 24), - tooltip: AppLocalizations.of(context)!.sendInvite, - onPressed: () { - _modalSendInvitation(context); - }), - IconButton( - icon: Provider.of(context, listen: false).isGroup == true ? Icon(CwtchIcons.group_settings_24px) : Icon(CwtchIcons.peer_settings_24px), - tooltip: AppLocalizations.of(context)!.conversationSettings, - onPressed: _pushContactSettings), - ], + actions: appBarButtons, ), body: Padding(padding: EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 108.0), child: MessageList(scrollController, scrollListener)), bottomSheet: _buildComposeBox(), @@ -189,6 +201,13 @@ class _MessageViewState extends State { _sendMessageHelper(); } + void _sendFile(String filePath) { + Provider.of(context, listen: false) + .cwtch + .ShareFile(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).onion, filePath); + _sendMessageHelper(); + } + void _sendMessageHelper() { ctrlrCompose.clear(); focusNode.requestFocus(); @@ -351,4 +370,20 @@ class _MessageViewState extends State { )); }); } + + void _showFilePicker() async { + FilePickerResult? result = await FilePicker.platform.pickFiles(); + if (result != null) { + File file = File(result.files.first.path); + // We have a maximum number of bytes we can represent in terms of + // a manifest (see : https://git.openprivacy.ca/cwtch.im/cwtch/src/branch/master/protocol/files/manifest.go#L25) + if (file.lengthSync() <= 10737418240) { + print("Sending " + file.path); + _sendFile(file.path); + } else { + print("file size cannot exceed 10 gigabytes"); + //todo: toast error + } + } + } } diff --git a/lib/widgets/filebubble.dart b/lib/widgets/filebubble.dart new file mode 100644 index 00000000..496e0924 --- /dev/null +++ b/lib/widgets/filebubble.dart @@ -0,0 +1,277 @@ +import 'dart:io'; + +import 'package:cwtch/models/message.dart'; +import 'package:file_picker_desktop/file_picker_desktop.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../main.dart'; +import '../model.dart'; +import 'package:intl/intl.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../settings.dart'; +import 'messagebubbledecorations.dart'; + +// Like MessageBubble but for displaying chat overlay 100/101 invitations +// Offers the user an accept/reject button if they don't have a matching contact already +class FileBubble extends StatefulWidget { + final String nameSuggestion; + final String rootHash; + final String nonce; + final int fileSize; + final bool interactive; + + FileBubble(this.nameSuggestion, this.rootHash, this.nonce, this.fileSize, {this.interactive = true}); + + @override + FileBubbleState createState() => FileBubbleState(); + + String fileKey() { + return this.rootHash + "." + this.nonce; + } +} + +class FileBubbleState extends State { + @override + Widget build(BuildContext context) { + var fromMe = Provider.of(context).senderHandle == Provider.of(context).onion; + var flagStarted = Provider.of(context).flags & 0x02 > 0; + var borderRadiousEh = 15.0; + var showFileSharing = Provider.of(context).isExperimentEnabled(FileSharingExperiment); + var prettyDate = DateFormat.yMd(Platform.localeName).add_jm().format(Provider.of(context).timestamp); + + // If the sender is not us, then we want to give them a nickname... + var senderDisplayStr = ""; + if (!fromMe) { + ContactInfoState? contact = Provider.of(context).contactList.getContact(Provider.of(context).senderHandle); + if (contact != null) { + senderDisplayStr = contact.nickname; + } else { + senderDisplayStr = Provider.of(context).senderHandle; + } + } + + var wdgSender = Center( + widthFactor: 1, + child: SelectableText(senderDisplayStr + '\u202F', + style: TextStyle(fontSize: 9.0, color: fromMe ? Provider.of(context).theme.messageFromMeTextColor() : Provider.of(context).theme.messageFromOtherTextColor()))); + + var wdgMessage = !showFileSharing + ? Text(AppLocalizations.of(context)!.messageEnableFileSharing) + : fromMe + ? senderFileChrome(AppLocalizations.of(context)!.messageFileSent, widget.nameSuggestion, widget.rootHash, widget.fileSize) + : (fileChrome(AppLocalizations.of(context)!.messageFileOffered + ":", widget.nameSuggestion, widget.rootHash, widget.fileSize, + Provider.of(context).downloadSpeed(widget.fileKey()))); + Widget wdgDecorations; + if (!showFileSharing) { + wdgDecorations = Text('\u202F'); + } else if (fromMe) { + wdgDecorations = MessageBubbleDecoration(ackd: Provider.of(context).ackd, errored: Provider.of(context).error, fromMe: fromMe, prettyDate: prettyDate); + } else if (Provider.of(context).downloadComplete(widget.fileKey())) { + // in this case, whatever marked download.complete would have also set the path + var path = Provider.of(context).downloadFinalPath(widget.fileKey())!; + wdgDecorations = Text('Saved to: ' + path + '\u202F'); + } else if (Provider.of(context).downloadActive(widget.fileKey())) { + if (!Provider.of(context).downloadGotManifest(widget.fileKey())) { + wdgDecorations = Text(AppLocalizations.of(context)!.retrievingManifestMessage + '\u202F'); + } else { + wdgDecorations = LinearProgressIndicator( + value: Provider.of(context).downloadProgress(widget.fileKey()), + color: Provider.of(context).theme.defaultButtonActiveColor(), + ); + } + } else if (flagStarted) { + // in this case, the download was done in a previous application launch, + // so we probably have to request an info lookup + var path = Provider.of(context).downloadFinalPath(widget.fileKey()); + if (path == null) { + wdgDecorations = Text('Checking download status...' + '\u202F'); + Provider.of(context, listen: false).cwtch.CheckDownloadStatus(Provider.of(context, listen: false).onion, widget.fileKey()); + } else { + wdgDecorations = Text('Saved to: ' + path + '\u202F'); + } + } else { + wdgDecorations = Center( + widthFactor: 1, + child: Wrap(children: [ + Padding(padding: EdgeInsets.all(5), child: ElevatedButton(child: Text(AppLocalizations.of(context)!.downloadFileButton + '\u202F'), onPressed: _btnAccept)), + ])); + } + + return LayoutBuilder(builder: (context, constraints) { + //print(constraints.toString()+", "+constraints.maxWidth.toString()); + return Center( + widthFactor: 1.0, + child: Container( + decoration: BoxDecoration( + color: fromMe ? Provider.of(context).theme.messageFromMeBackgroundColor() : Provider.of(context).theme.messageFromOtherBackgroundColor(), + border: + Border.all(color: fromMe ? Provider.of(context).theme.messageFromMeBackgroundColor() : Provider.of(context).theme.messageFromOtherBackgroundColor(), width: 1), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(borderRadiousEh), + topRight: Radius.circular(borderRadiousEh), + bottomLeft: fromMe ? Radius.circular(borderRadiousEh) : Radius.zero, + bottomRight: fromMe ? Radius.zero : Radius.circular(borderRadiousEh), + ), + ), + child: Center( + widthFactor: 1.0, + child: Padding( + padding: EdgeInsets.all(9.0), + child: Wrap(alignment: WrapAlignment.start, children: [ + Center( + widthFactor: 1.0, + child: Column( + crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + mainAxisAlignment: fromMe ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: fromMe + ? [wdgMessage, Visibility(visible: widget.interactive, child: wdgDecorations)] + : [wdgSender, wdgMessage, Visibility(visible: widget.interactive, child: wdgDecorations)]), + ) + ]))))); + }); + } + + void _btnAccept() async { + String? selectedFileName; + File? file; + var profileOnion = Provider.of(context, listen: false).onion; + var handle = Provider.of(context, listen: false).senderHandle; + var contact = Provider.of(context, listen: false).onion; + var idx = Provider.of(context, listen: false).messageIndex; + + if (Platform.isAndroid) { + Provider.of(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil()); + Provider.of(context, listen: false).cwtch.UpdateMessageFlags(profileOnion, contact, idx, Provider.of(context, listen: false).flags | 0x02); + Provider.of(context, listen: false).flags |= 0x02; + Provider.of(context, listen: false).cwtch.CreateDownloadableFile(profileOnion, handle, widget.nameSuggestion, widget.fileKey()); + } else { + try { + selectedFileName = await saveFile( + defaultFileName: widget.nameSuggestion, + ); + if (selectedFileName != null) { + file = File(selectedFileName); + print("saving to " + file.path); + var manifestPath = file.path + ".manifest"; + Provider.of(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil()); + Provider.of(context, listen: false).cwtch.UpdateMessageFlags(profileOnion, contact, idx, Provider.of(context, listen: false).flags | 0x02); + Provider.of(context, listen: false).flags |= 0x02; + Provider.of(context, listen: false).cwtch.DownloadFile(profileOnion, handle, file.path, manifestPath, widget.fileKey()); + } + } catch (e) { + print(e); + } + } + } + + // Construct an file chrome for the sender + Widget senderFileChrome(String chrome, String fileName, String rootHash, int fileSize) { + return ListTile( + visualDensity: VisualDensity.compact, + title: Wrap(direction: Axis.horizontal, alignment: WrapAlignment.start, children: [ + SelectableText( + chrome + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 2, + textWidthBasis: TextWidthBasis.longestLine, + ), + SelectableText( + fileName + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + textAlign: TextAlign.left, + textWidthBasis: TextWidthBasis.parent, + maxLines: 2, + ), + SelectableText( + prettyBytes(fileSize) + '\u202F' + '\n', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 2, + ) + ]), + subtitle: SelectableText( + 'sha512: ' + rootHash + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + fontSize: 10, + fontFamily: "monospace", + ), + textAlign: TextAlign.left, + maxLines: 4, + textWidthBasis: TextWidthBasis.parent, + ), + leading: Icon(Icons.attach_file, size: 32, color: Provider.of(context).theme.messageFromMeTextColor())); + } + + // Construct an file chrome + Widget fileChrome(String chrome, String fileName, String rootHash, int fileSize, String speed) { + return ListTile( + visualDensity: VisualDensity.compact, + title: Wrap(direction: Axis.horizontal, alignment: WrapAlignment.start, children: [ + SelectableText( + chrome + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromOtherTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 2, + textWidthBasis: TextWidthBasis.longestLine, + ), + SelectableText( + fileName + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromOtherTextColor(), + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + textAlign: TextAlign.left, + textWidthBasis: TextWidthBasis.parent, + maxLines: 2, + ), + SelectableText( + AppLocalizations.of(context)!.labelFilesize + ': ' + prettyBytes(fileSize) + '\u202F' + '\n', + style: TextStyle( + color: Provider.of(context).theme.messageFromOtherTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 2, + ) + ]), + subtitle: SelectableText( + 'sha512: ' + rootHash + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + fontSize: 10, + fontFamily: "monospace", + ), + textAlign: TextAlign.left, + maxLines: 4, + textWidthBasis: TextWidthBasis.parent, + ), + leading: Icon(Icons.attach_file, size: 32, color: Provider.of(context).theme.messageFromOtherTextColor()), + trailing: Visibility( + visible: speed != "0 B/s", + child: SelectableText( + speed + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 1, + textWidthBasis: TextWidthBasis.longestLine, + )), + ); + } +} diff --git a/lib/widgets/invitationbubble.dart b/lib/widgets/invitationbubble.dart index 3a9c2585..0e6b9219 100644 --- a/lib/widgets/invitationbubble.dart +++ b/lib/widgets/invitationbubble.dart @@ -131,7 +131,7 @@ class InvitationBubbleState extends State { var contact = Provider.of(context, listen: false).onion; var idx = Provider.of(context, listen: false).messageIndex; Provider.of(context, listen: false).cwtch.UpdateMessageFlags(profileOnion, contact, idx, Provider.of(context, listen: false).flags | 0x01); - Provider.of(context).flags |= 0x01; + Provider.of(context, listen: false).flags |= 0x01; }); } diff --git a/lib/widgets/messagelist.dart b/lib/widgets/messagelist.dart index a6a8353d..f541a12d 100644 --- a/lib/widgets/messagelist.dart +++ b/lib/widgets/messagelist.dart @@ -87,7 +87,7 @@ class _MessageListState extends State { // Already includes MessageRow,, return message.getWidget(context); } else { - return MessageLoadingBubble(); + return Text(''); //MessageLoadingBubble(); } }, ); diff --git a/lib/widgets/messagerow.dart b/lib/widgets/messagerow.dart index 69e97a00..efaa670f 100644 --- a/lib/widgets/messagerow.dart +++ b/lib/widgets/messagerow.dart @@ -163,7 +163,7 @@ class MessageRowState extends State with SingleTickerProviderStateMi // For desktop... onHover: (event) { setState(() { - Provider.of(context, listen: false).hoveredIndex = Provider.of(context).messageIndex; + Provider.of(context, listen: false).hoveredIndex = Provider.of(context, listen: false).messageIndex; }); }, onExit: (event) { diff --git a/pubspec.lock b/pubspec.lock index b8117b89..ca0185ec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -106,6 +106,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.2" + file_picker: + dependency: "direct main" + description: + name: file_picker + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + file_picker_desktop: + dependency: "direct main" + description: + name: file_picker_desktop + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" flutter: dependency: "direct main" description: flutter @@ -116,6 +130,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" flutter_test: dependency: "direct main" description: flutter @@ -427,4 +448,4 @@ packages: version: "3.1.0" sdks: dart: ">=2.13.0 <3.0.0" - flutter: ">=1.20.0" + flutter: ">=2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index c7d1f0fb..d0ffcee0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,8 @@ dependencies: flutter_test: sdk: flutter scrollable_positioned_list: ^0.2.0-nullsafety.0 + file_picker: ^4.0.1 + file_picker_desktop: ^1.1.0 dev_dependencies: msix: ^2.1.3