From cb3c16127770586918b1b1ced5e414edbaa6d02b Mon Sep 17 00:00:00 2001 From: erinn Date: Mon, 27 Sep 2021 12:53:21 -0700 Subject: [PATCH] wip: filesharing ui dev --- android/app/build.gradle | 4 +- android/app/local.properties | 8 +++ android/app/src/main/AndroidManifest.xml | 9 ++- .../kotlin/im/cwtch/flwtch/FlwtchWorker.kt | 37 ++++++++++++ .../kotlin/im/cwtch/flwtch/MainActivity.kt | 60 ++++++++++++++----- android/build.gradle | 2 +- lib/model.dart | 31 +++++++++- lib/views/globalsettingsview.dart | 2 +- lib/views/messageview.dart | 3 + lib/widgets/filebubble.dart | 28 +++++---- lib/widgets/messagerow.dart | 2 +- pubspec.lock | 29 +++++++-- pubspec.yaml | 2 + 13 files changed, 180 insertions(+), 37 deletions(-) create mode 100644 android/app/local.properties 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/local.properties b/android/app/local.properties new file mode 100644 index 00000000..3b474cda --- /dev/null +++ b/android/app/local.properties @@ -0,0 +1,8 @@ +## This file must *NOT* be checked into Version Control Systems, +# as it contains information specific to your local configuration. +# +# Location of the SDK. This is only used by Gradle. +# For customization when using a Version Control System, please read the +# header note. +#Fri Jul 02 15:08:54 PDT 2021 +sdk.dir=/home/erinn/Android/Sdk 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..acb83e5f 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) { @@ -93,6 +97,24 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) : .build() notificationManager.notify(getNotificationID(data.getString("ProfileOnion"), handle), newNotification) } + } else if (evt.EventType == "FileDownloaded") { + Log.i("FlwtchWorker", "file downloaded!"); + val data = JSONObject(evt.Data); + val tempFile = data.getString("TempFile"); + 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); + } + } } Intent().also { intent -> @@ -157,6 +179,21 @@ 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) + } "SendProfileEvent" -> { val onion = (a.get("onion") as? String) ?: "" val jsonEvent = (a.get("jsonEvent") as? String) ?: "" 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..8c7d1f47 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,11 @@ 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 = "" + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) if (notificationClickChannel == null || intent.extras == null) return @@ -68,6 +81,25 @@ class MainActivity: FlutterActivity() { } } + 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(); + Log.i("onActivityResult", "got download path: " + filePath); + Log.i("onActivityResult", "got manifest path: " + manifestPath); + handleCwtch(MethodCall("DownloadFile", mapOf( + "ProfileOnion" to this.dlToProfile, + "handle" to this.dlToHandle, + "filepath" to filePath, + "manifestpath" to manifestPath, + "filekey" to this.dlToFileKey + )), ErrorLogResult(""));//placeholder; 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/model.dart b/lib/model.dart index ff7a00a0..9dcac693 100644 --- a/lib/model.dart +++ b/lib/model.dart @@ -360,7 +360,7 @@ class ProfileInfoState extends ChangeNotifier { } void downloadInit(String fileKey, int numChunks) { - this._downloads[fileKey] = FileDownloadProgress(numChunks); + this._downloads[fileKey] = FileDownloadProgress(numChunks, DateTime.now()); } void downloadUpdate(String fileKey, int progress) { @@ -385,6 +385,7 @@ class ProfileInfoState extends ChangeNotifier { if (!downloadActive(fileKey)) { print("error: received download completion notice for unknown download "+fileKey); } else { + this._downloads[fileKey]!.timeEnd = DateTime.now(); this._downloads[fileKey]!.complete = true; notifyListeners(); } @@ -405,6 +406,18 @@ class ProfileInfoState extends ChangeNotifier { double downloadProgress(String fileKey) { return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.progress() : 0.0; } + + String downloadSpeed(String fileKey) { + if (!downloadActive(fileKey)) { + 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 { @@ -412,13 +425,27 @@ class FileDownloadProgress { int chunksTotal = 1; bool complete = false; bool gotManifest = false; + DateTime? timeStart; + DateTime? timeEnd; - FileDownloadProgress(this.chunksTotal); + 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 } ContactAuthorization stringToContactAuthorization(String authStr) { diff --git a/lib/views/globalsettingsview.dart b/lib/views/globalsettingsview.dart index 373100d7..fb85e3b1 100644 --- a/lib/views/globalsettingsview.dart +++ b/lib/views/globalsettingsview.dart @@ -189,7 +189,7 @@ class _GlobalSettingsViewState extends State { secondary: Icon(CwtchIcons.enable_groups, color: settings.current().mainTextColor()), ), SwitchListTile( - title: Text(AppLocalizations.of(context)!.labelFileSharing, style: TextStyle(color: settings.current().mainTextColor())), + 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) { diff --git a/lib/views/messageview.dart b/lib/views/messageview.dart index 2c1c158c..0dcb9e07 100644 --- a/lib/views/messageview.dart +++ b/lib/views/messageview.dart @@ -40,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] diff --git a/lib/widgets/filebubble.dart b/lib/widgets/filebubble.dart index 698325e5..7286f7cf 100644 --- a/lib/widgets/filebubble.dart +++ b/lib/widgets/filebubble.dart @@ -63,9 +63,9 @@ class FileBubbleState extends State { var wdgMessage = !showFileSharing ? Text(AppLocalizations.of(context)!.messageEnableFileSharing) : fromMe - ? senderInviteChrome( + ? senderFileChrome( AppLocalizations.of(context)!.messageFileSent, widget.nameSuggestion, widget.rootHash, widget.fileSize) - : (inviteChrome(AppLocalizations.of(context)!.messageFileOffered + ":", 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) { @@ -147,9 +147,8 @@ class FileBubbleState extends State { file = File(selectedFileName); print("saving to " + file.path); var manifestPath = file.path + ".manifest"; - setState(() { - Provider.of(context, listen: false).cwtch.DownloadFile(profileOnion, handle, file!.path, manifestPath, widget.fileKey()); - }); + Provider.of(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil()); + Provider.of(context, listen: false).cwtch.DownloadFile(profileOnion, handle, file.path, manifestPath, widget.fileKey()); } } catch (e) { print(e); @@ -158,8 +157,8 @@ class FileBubbleState extends State { } // Construct an invite chrome for the sender - Widget senderInviteChrome(String chrome, String fileName, String rootHash, int fileSize) { - return Wrap(children: [ + Widget senderFileChrome(String chrome, String fileName, String rootHash, int fileSize) { + return Wrap(direction: Axis.vertical,children: [ SelectableText( chrome + '\u202F', style: TextStyle( @@ -179,7 +178,7 @@ class FileBubbleState extends State { textWidthBasis: TextWidthBasis.longestLine, ), SelectableText( - fileSize.toString() + 'B\u202F', + prettyBytes(fileSize) + '\u202F', style: TextStyle( color: Provider.of(context).theme.messageFromMeTextColor(), ), @@ -200,7 +199,7 @@ class FileBubbleState extends State { } // Construct an invite chrome - Widget inviteChrome(String chrome, String fileName, String rootHash, int fileSize) { + Widget fileChrome(String chrome, String fileName, String rootHash, int fileSize, String speed) { var prettyHash = rootHash; if (rootHash.length == 128) { prettyHash = rootHash.substring(0, 32) + '\n' + @@ -230,7 +229,7 @@ class FileBubbleState extends State { textWidthBasis: TextWidthBasis.longestLine, ), SelectableText( - AppLocalizations.of(context)!.labelFilesize + ': ' + fileSize.toString() + 'B\u202F', + AppLocalizations.of(context)!.labelFilesize + ': ' + prettyBytes(fileSize) + '\u202F', style: TextStyle( color: Provider.of(context).theme.messageFromMeTextColor(), ), @@ -247,6 +246,15 @@ class FileBubbleState extends State { maxLines: 4, textWidthBasis: TextWidthBasis.longestLine, ), + SelectableText( + speed + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 1, + textWidthBasis: TextWidthBasis.longestLine, + ), ]); } } 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..41cdef38 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,7 +21,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.8.1" + version: "2.8.2" boolean_selector: dependency: transitive description: @@ -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 @@ -174,7 +195,7 @@ packages: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.10" + version: "0.12.11" meta: dependency: transitive description: @@ -382,7 +403,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.4.2" + version: "0.4.3" typed_data: dependency: transitive description: @@ -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