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 {
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
}

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:label="Cwtch"
android:extractNativeLibs="true"
android:icon="@mipmap/knott">
android:icon="@mipmap/knott"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
@ -48,4 +49,10 @@
<!--Meeded to check if activity is foregrounded or if messages from the service should be queued-->
<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>

View File

@ -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) ?: ""

View File

@ -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<FlwtchWorker>(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")

View File

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

View File

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

View File

@ -189,7 +189,7 @@ class _GlobalSettingsViewState extends State<GlobalSettingsView> {
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) {

View File

@ -40,6 +40,9 @@ class _MessageViewState extends State<MessageView> {
@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]

View File

@ -63,9 +63,9 @@ class FileBubbleState extends State<FileBubble> {
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<ProfileInfoState>(context).downloadSpeed(widget.fileKey())));
Widget wdgDecorations;
if (!showFileSharing) {
@ -147,9 +147,8 @@ class FileBubbleState extends State<FileBubble> {
file = File(selectedFileName);
print("saving to " + file.path);
var manifestPath = file.path + ".manifest";
setState(() {
Provider.of<FlwtchState>(context, listen: false).cwtch.DownloadFile(profileOnion, handle, file!.path, manifestPath, widget.fileKey());
});
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());
}
} catch (e) {
print(e);
@ -158,8 +157,8 @@ class FileBubbleState extends State<FileBubble> {
}
// 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<FileBubble> {
textWidthBasis: TextWidthBasis.longestLine,
),
SelectableText(
fileSize.toString() + 'B\u202F',
prettyBytes(fileSize) + '\u202F',
style: TextStyle(
color: Provider.of<Settings>(context).theme.messageFromMeTextColor(),
),
@ -200,7 +199,7 @@ class FileBubbleState extends State<FileBubble> {
}
// 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<FileBubble> {
textWidthBasis: TextWidthBasis.longestLine,
),
SelectableText(
AppLocalizations.of(context)!.labelFilesize + ': ' + fileSize.toString() + 'B\u202F',
AppLocalizations.of(context)!.labelFilesize + ': ' + prettyBytes(fileSize) + '\u202F',
style: TextStyle(
color: Provider.of<Settings>(context).theme.messageFromMeTextColor(),
),
@ -247,6 +246,15 @@ class FileBubbleState extends State<FileBubble> {
maxLines: 4,
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...
onHover: (event) {
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) {

View File

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

View File

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