forked from cwtch.im/cwtch-ui
wip: filesharing ui dev
This commit is contained in:
parent
4eed72ded3
commit
cb3c161277
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
@ -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) ?: ""
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
29
pubspec.lock
29
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"
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue