wip: filesharing ui dev

This commit is contained in:
erinn 2021-09-27 12:53:21 -07:00
parent 4eed72ded3
commit cb3c161277
13 changed files with 180 additions and 37 deletions

View File

@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) {
} }
android { android {
compileSdkVersion 29 compileSdkVersion 30
sourceSets { sourceSets {
main.java.srcDirs += 'src/main/kotlin' 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). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "im.cwtch.flwtch" applicationId "im.cwtch.flwtch"
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 29 targetSdkVersion 30
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
} }

View File

@ -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

View File

@ -9,7 +9,8 @@
android:name="io.flutter.app.FlutterApplication" android:name="io.flutter.app.FlutterApplication"
android:label="Cwtch" android:label="Cwtch"
android:extractNativeLibs="true" android:extractNativeLibs="true"
android:icon="@mipmap/knott"> android:icon="@mipmap/knott"
android:requestLegacyExternalStorage="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:launchMode="singleTop" android:launchMode="singleTop"
@ -48,4 +49,10 @@
<!--Meeded to check if activity is foregrounded or if messages from the service should be queued--> <!--Meeded to check if activity is foregrounded or if messages from the service should be queued-->
<uses-permission android:name="android.permission.GET_TASKS" /> <uses-permission android:name="android.permission.GET_TASKS" />
<!--Needed to download files-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.PERMISSIONS_STORAGE" />
</manifest> </manifest>

View File

@ -15,6 +15,10 @@ import cwtch.Cwtch
import io.flutter.FlutterInjector import io.flutter.FlutterInjector
import org.json.JSONObject 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) : class FlwtchWorker(context: Context, parameters: WorkerParameters) :
CoroutineWorker(context, parameters) { CoroutineWorker(context, parameters) {
@ -93,6 +97,24 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) :
.build() .build()
notificationManager.notify(getNotificationID(data.getString("ProfileOnion"), handle), newNotification) 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 -> Intent().also { intent ->
@ -157,6 +179,21 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) :
val target = (a.get("target") as? String) ?: "" val target = (a.get("target") as? String) ?: ""
Cwtch.sendInvitation(profile, handle, target) 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" -> { "SendProfileEvent" -> {
val onion = (a.get("onion") as? String) ?: "" val onion = (a.get("onion") as? String) ?: ""
val jsonEvent = (a.get("jsonEvent") as? String) ?: "" val jsonEvent = (a.get("jsonEvent") as? String) ?: ""

View File

@ -12,17 +12,25 @@ import android.view.Window
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.* import androidx.work.*
import io.flutter.embedding.android.SplashScreen import io.flutter.embedding.android.SplashScreen
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.ErrorLogResult
import org.json.JSONObject import org.json.JSONObject
import java.util.concurrent.TimeUnit 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() { class MainActivity: FlutterActivity() {
override fun provideSplashScreen(): SplashScreen? = SplashView() override fun provideSplashScreen(): SplashScreen? = SplashView()
@ -47,6 +55,11 @@ class MainActivity: FlutterActivity() {
private var notificationClickChannel: MethodChannel? = null private var notificationClickChannel: MethodChannel? = null
private var shutdownClickChannel: 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) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
if (notificationClickChannel == null || intent.extras == null) return 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) { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
// Note: this methods are invoked on the main thread. // Note: this methods are invoked on the main thread.
@ -125,6 +157,18 @@ class MainActivity: FlutterActivity() {
val workRequest = PeriodicWorkRequestBuilder<FlwtchWorker>(15, TimeUnit.MINUTES).setInputData(data).addTag(WORKER_TAG).addTag(uniqueTag).build() val workRequest = PeriodicWorkRequestBuilder<FlwtchWorker>(15, TimeUnit.MINUTES).setInputData(data).addTag(WORKER_TAG).addTag(uniqueTag).build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork("req_$uniqueTag", ExistingPeriodicWorkPolicy.REPLACE, workRequest) WorkManager.getInstance(this).enqueueUniquePeriodicWork("req_$uniqueTag", ExistingPeriodicWorkPolicy.REPLACE, workRequest)
return 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) // ...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() 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) { class AppbusEvent(json: String) : JSONObject(json) {
val EventType = this.optString("EventType") val EventType = this.optString("EventType")
val EventID = this.optString("EventID") val EventID = this.optString("EventID")

View File

@ -6,7 +6,7 @@ buildscript {
} }
dependencies { 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" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }

View File

@ -360,7 +360,7 @@ class ProfileInfoState extends ChangeNotifier {
} }
void downloadInit(String fileKey, int numChunks) { void downloadInit(String fileKey, int numChunks) {
this._downloads[fileKey] = FileDownloadProgress(numChunks); this._downloads[fileKey] = FileDownloadProgress(numChunks, DateTime.now());
} }
void downloadUpdate(String fileKey, int progress) { void downloadUpdate(String fileKey, int progress) {
@ -385,6 +385,7 @@ class ProfileInfoState extends ChangeNotifier {
if (!downloadActive(fileKey)) { if (!downloadActive(fileKey)) {
print("error: received download completion notice for unknown download "+fileKey); print("error: received download completion notice for unknown download "+fileKey);
} else { } else {
this._downloads[fileKey]!.timeEnd = DateTime.now();
this._downloads[fileKey]!.complete = true; this._downloads[fileKey]!.complete = true;
notifyListeners(); notifyListeners();
} }
@ -405,6 +406,18 @@ class ProfileInfoState extends ChangeNotifier {
double downloadProgress(String fileKey) { double downloadProgress(String fileKey) {
return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.progress() : 0.0; 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 { class FileDownloadProgress {
@ -412,13 +425,27 @@ class FileDownloadProgress {
int chunksTotal = 1; int chunksTotal = 1;
bool complete = false; bool complete = false;
bool gotManifest = false; bool gotManifest = false;
DateTime? timeStart;
DateTime? timeEnd;
FileDownloadProgress(this.chunksTotal); FileDownloadProgress(this.chunksTotal, this.timeStart);
double progress() { double progress() {
return 1.0 * chunksDownloaded / chunksTotal; 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 } enum ContactAuthorization { unknown, approved, blocked }
ContactAuthorization stringToContactAuthorization(String authStr) { ContactAuthorization stringToContactAuthorization(String authStr) {

View File

@ -189,7 +189,7 @@ class _GlobalSettingsViewState extends State<GlobalSettingsView> {
secondary: Icon(CwtchIcons.enable_groups, color: settings.current().mainTextColor()), secondary: Icon(CwtchIcons.enable_groups, color: settings.current().mainTextColor()),
), ),
SwitchListTile( 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), subtitle: Text(AppLocalizations.of(context)!.descriptionFileSharing),
value: settings.isExperimentEnabled(FileSharingExperiment), value: settings.isExperimentEnabled(FileSharingExperiment),
onChanged: (bool value) { onChanged: (bool value) {

View File

@ -40,6 +40,9 @@ class _MessageViewState extends State<MessageView> {
@override @override
void initState() { void initState() {
scrollListener.itemPositions.addListener(() { scrollListener.itemPositions.addListener(() {
if (scrollListener.itemPositions.value.length == 0) {
return;
}
var first = scrollListener.itemPositions.value.first.index; var first = scrollListener.itemPositions.value.first.index;
var last = scrollListener.itemPositions.value.last.index; var last = scrollListener.itemPositions.value.last.index;
// sometimes these go hi->lo and sometimes they go lo->hi because [who tf knows] // sometimes these go hi->lo and sometimes they go lo->hi because [who tf knows]

View File

@ -63,9 +63,9 @@ class FileBubbleState extends State<FileBubble> {
var wdgMessage = !showFileSharing var wdgMessage = !showFileSharing
? Text(AppLocalizations.of(context)!.messageEnableFileSharing) ? Text(AppLocalizations.of(context)!.messageEnableFileSharing)
: fromMe : fromMe
? senderInviteChrome( ? senderFileChrome(
AppLocalizations.of(context)!.messageFileSent, widget.nameSuggestion, widget.rootHash, widget.fileSize) 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<ProfileInfoState>(context).downloadSpeed(widget.fileKey())));
Widget wdgDecorations; Widget wdgDecorations;
if (!showFileSharing) { if (!showFileSharing) {
@ -147,9 +147,8 @@ class FileBubbleState extends State<FileBubble> {
file = File(selectedFileName); file = File(selectedFileName);
print("saving to " + file.path); print("saving to " + file.path);
var manifestPath = file.path + ".manifest"; var manifestPath = file.path + ".manifest";
setState(() { Provider.of<ProfileInfoState>(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil());
Provider.of<FlwtchState>(context, listen: false).cwtch.DownloadFile(profileOnion, handle, file!.path, manifestPath, widget.fileKey()); Provider.of<FlwtchState>(context, listen: false).cwtch.DownloadFile(profileOnion, handle, file.path, manifestPath, widget.fileKey());
});
} }
} catch (e) { } catch (e) {
print(e); print(e);
@ -158,8 +157,8 @@ class FileBubbleState extends State<FileBubble> {
} }
// Construct an invite chrome for the sender // Construct an invite chrome for the sender
Widget senderInviteChrome(String chrome, String fileName, String rootHash, int fileSize) { Widget senderFileChrome(String chrome, String fileName, String rootHash, int fileSize) {
return Wrap(children: [ return Wrap(direction: Axis.vertical,children: [
SelectableText( SelectableText(
chrome + '\u202F', chrome + '\u202F',
style: TextStyle( style: TextStyle(
@ -179,7 +178,7 @@ class FileBubbleState extends State<FileBubble> {
textWidthBasis: TextWidthBasis.longestLine, textWidthBasis: TextWidthBasis.longestLine,
), ),
SelectableText( SelectableText(
fileSize.toString() + 'B\u202F', prettyBytes(fileSize) + '\u202F',
style: TextStyle( style: TextStyle(
color: Provider.of<Settings>(context).theme.messageFromMeTextColor(), color: Provider.of<Settings>(context).theme.messageFromMeTextColor(),
), ),
@ -200,7 +199,7 @@ class FileBubbleState extends State<FileBubble> {
} }
// Construct an invite chrome // 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; var prettyHash = rootHash;
if (rootHash.length == 128) { if (rootHash.length == 128) {
prettyHash = rootHash.substring(0, 32) + '\n' + prettyHash = rootHash.substring(0, 32) + '\n' +
@ -230,7 +229,7 @@ class FileBubbleState extends State<FileBubble> {
textWidthBasis: TextWidthBasis.longestLine, textWidthBasis: TextWidthBasis.longestLine,
), ),
SelectableText( SelectableText(
AppLocalizations.of(context)!.labelFilesize + ': ' + fileSize.toString() + 'B\u202F', AppLocalizations.of(context)!.labelFilesize + ': ' + prettyBytes(fileSize) + '\u202F',
style: TextStyle( style: TextStyle(
color: Provider.of<Settings>(context).theme.messageFromMeTextColor(), color: Provider.of<Settings>(context).theme.messageFromMeTextColor(),
), ),
@ -247,6 +246,15 @@ class FileBubbleState extends State<FileBubble> {
maxLines: 4, maxLines: 4,
textWidthBasis: TextWidthBasis.longestLine, textWidthBasis: TextWidthBasis.longestLine,
), ),
SelectableText(
speed + '\u202F',
style: TextStyle(
color: Provider.of<Settings>(context).theme.messageFromMeTextColor(),
),
textAlign: TextAlign.left,
maxLines: 1,
textWidthBasis: TextWidthBasis.longestLine,
),
]); ]);
} }
} }

View File

@ -163,7 +163,7 @@ class MessageRowState extends State<MessageRow> with SingleTickerProviderStateMi
// For desktop... // For desktop...
onHover: (event) { onHover: (event) {
setState(() { setState(() {
Provider.of<AppState>(context, listen: false).hoveredIndex = Provider.of<MessageMetadata>(context).messageIndex; Provider.of<AppState>(context, listen: false).hoveredIndex = Provider.of<MessageMetadata>(context, listen: false).messageIndex;
}); });
}, },
onExit: (event) { onExit: (event) {

View File

@ -21,7 +21,7 @@ packages:
name: async name: async
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.8.1" version: "2.8.2"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -106,6 +106,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.2" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -116,6 +130,13 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_test:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -174,7 +195,7 @@ packages:
name: matcher name: matcher
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.12.10" version: "0.12.11"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -382,7 +403,7 @@ packages:
name: test_api name: test_api
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.2" version: "0.4.3"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -427,4 +448,4 @@ packages:
version: "3.1.0" version: "3.1.0"
sdks: sdks:
dart: ">=2.13.0 <3.0.0" dart: ">=2.13.0 <3.0.0"
flutter: ">=1.20.0" flutter: ">=2.0.0"

View File

@ -41,6 +41,8 @@ dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
scrollable_positioned_list: ^0.2.0-nullsafety.0 scrollable_positioned_list: ^0.2.0-nullsafety.0
file_picker: ^4.0.1
file_picker_desktop: ^1.1.0
dev_dependencies: dev_dependencies:
msix: ^2.1.3 msix: ^2.1.3