diff --git a/LIBCWTCH-GO-MACOS.version b/LIBCWTCH-GO-MACOS.version index 368ca44f..6d5fa28d 100644 --- a/LIBCWTCH-GO-MACOS.version +++ b/LIBCWTCH-GO-MACOS.version @@ -1 +1 @@ -2022-03-10-17-32-v1.6.0-6-gc2874db \ No newline at end of file +2022-03-10-19-05-v1.6.0-9-g5b34715 \ No newline at end of file diff --git a/LIBCWTCH-GO.version b/LIBCWTCH-GO.version index 79a33b2e..f4acd5e9 100644 --- a/LIBCWTCH-GO.version +++ b/LIBCWTCH-GO.version @@ -1 +1 @@ -2022-03-10-22-49-v1.6.0-6-gc2874db \ No newline at end of file +2022-03-11-00-06-v1.6.0-9-g5b34715 \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d95c4e96..ff06f437 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -46,7 +46,7 @@ - + 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 52037442..35980b5e 100644 --- a/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt +++ b/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt @@ -1,6 +1,7 @@ package im.cwtch.flwtch import android.app.* +import android.os.Environment import android.content.Context import android.content.Intent import android.graphics.BitmapFactory @@ -428,6 +429,17 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) : notificationConversationInfo = (a.get("notificationConversationInfo") as? String) ?: "New Message From " } + "ExportProfile" -> { + val profileOnion = (a.get("ProfileOnion") as? String) ?: "" + val file = StringBuilder().append(this.applicationContext.cacheDir).append("/").append((a.get("file") as? String) ?: "").toString() + Log.i("FlwtchWorker", "constructing exported file " + file); + Cwtch.exportProfile(profileOnion,file) + } + "ImportProfile" -> { + val file = (a.get("file") as? String) ?: "" + val pass = (a.get("pass") as? String) ?: "" + return Result.success(Data.Builder().putString("result", Cwtch.importProfile(file, pass)).build()); + } else -> { Log.i(TAG, "unknown command: " + method); return Result.failure() 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 499c01ce..383887bb 100644 --- a/android/app/src/main/kotlin/im/cwtch/flwtch/MainActivity.kt +++ b/android/app/src/main/kotlin/im/cwtch/flwtch/MainActivity.kt @@ -62,6 +62,7 @@ class MainActivity: FlutterActivity() { // "Download to..." prompt extra arguments private val FILEPICKER_REQUEST_CODE = 234 private val PREVIEW_EXPORT_REQUEST_CODE = 235 + private val PROFILE_EXPORT_REQUEST_CODE = 236 private var dlToProfile = "" private var dlToHandle = "" private var dlToFileKey = "" @@ -110,8 +111,6 @@ class MainActivity: FlutterActivity() { )), ErrorLogResult(""));//placeholder; this Result is never actually invoked } else if (requestCode == PREVIEW_EXPORT_REQUEST_CODE) { val targetPath = intent!!.getData().toString() - var srcFile = File(this.exportFromPath) - Log.i("MainActivity:PREVIEW_EXPORT", "exporting previewed file") val sourcePath = Paths.get(this.exportFromPath); val targetUri = Uri.parse(targetPath); val os = this.applicationContext.getContentResolver().openOutputStream(targetUri); @@ -122,6 +121,20 @@ class MainActivity: FlutterActivity() { os?.close(); //Files.delete(sourcePath); } + } else if (requestCode == PROFILE_EXPORT_REQUEST_CODE ) { + val targetPath = intent!!.getData().toString() + val srcFile = StringBuilder().append(this.applicationContext.cacheDir).append("/").append(this.exportFromPath).toString(); + Log.i("MainActivity:PREVIEW_EXPORT", "exporting previewed file " + srcFile); + val sourcePath = Paths.get(srcFile); + val targetUri = Uri.parse(targetPath); + val os = this.applicationContext.getContentResolver().openOutputStream(targetUri); + val bytesWritten = Files.copy(sourcePath, os); + Log.d("MainActivity:PREVIEW_EXPORT", "copied " + bytesWritten.toString() + " bytes"); + if (bytesWritten != 0L) { + os?.flush(); + os?.close(); + //Files.delete(sourcePath); + } } } @@ -211,6 +224,14 @@ class MainActivity: FlutterActivity() { } startActivityForResult(intent, PREVIEW_EXPORT_REQUEST_CODE) return + } else if (call.method == "ExportProfile") { + this.exportFromPath = argmap["file"] ?: "" + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/gzip" + putExtra(Intent.EXTRA_TITLE, argmap["file"]) + } + startActivityForResult(intent, PROFILE_EXPORT_REQUEST_CODE) } // ...otherwise fallthru to a normal ffi method call (and return the result using the result callback) diff --git a/lib/controllers/enter_password.dart b/lib/controllers/enter_password.dart new file mode 100644 index 00000000..92e5af7a --- /dev/null +++ b/lib/controllers/enter_password.dart @@ -0,0 +1,43 @@ +import 'package:cwtch/widgets/passwordfield.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +void showPasswordDialog(BuildContext context, String title, String action, Function(String) onEntered) { + TextEditingController passwordController = TextEditingController(); + CwtchPasswordField passwordField = CwtchPasswordField( + controller: passwordController, + validator: (passsword) { + return null; + }); + + // set up the buttons + Widget cancelButton = ElevatedButton( + child: Text(AppLocalizations.of(context)!.cancel), + onPressed: () { + Navigator.of(context).pop(); // dismiss dialog + }, + ); + Widget continueButton = ElevatedButton( + child: Text(action), + onPressed: () { + onEntered(passwordController.value.text); + }); + + // set up the AlertDialog + AlertDialog alert = AlertDialog( + title: Text(title), + content: passwordField, + actions: [ + cancelButton, + continueButton, + ], + ); + + // show the dialog + showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + ); +} diff --git a/lib/controllers/filesharing.dart b/lib/controllers/filesharing.dart index cbfdaa20..49da9bdf 100644 --- a/lib/controllers/filesharing.dart +++ b/lib/controllers/filesharing.dart @@ -28,3 +28,17 @@ void showFilePicker(BuildContext ctx, int maxBytes, Function(File) onSuccess, Fu onCancel(); } } + +Future showCreateFilePicker(BuildContext ctx) async { + // only allow one file picker at a time + // note: ideally we would destroy file picker when leaving a conversation + // but we don't currently have that option. + // we need to store AppState in a variable because ctx might be destroyed + // while awaiting for pickFiles. + var appstate = Provider.of(ctx, listen: false); + appstate.disableFilePicker = true; + // currently lockParentWindow only works on Windows... + String? result = await FilePicker.platform.saveFile(lockParentWindow: true); + appstate.disableFilePicker = false; + return result; +} diff --git a/lib/cwtch/cwtch.dart b/lib/cwtch/cwtch.dart index b06040f6..e4a65896 100644 --- a/lib/cwtch/cwtch.dart +++ b/lib/cwtch/cwtch.dart @@ -1,5 +1,3 @@ -import 'package:flutter/src/services/text_input.dart'; - // To handle profiles that are "unencrypted" (i.e don't require a password to open) we currently create a profile with a defacto, hardcoded password. // Details: https://docs.openprivacy.ca/cwtch-security-handbook/profile_encryption_and_storage.html const DefaultPassword = "be gay do crime"; @@ -19,6 +17,11 @@ abstract class Cwtch { // ignore: non_constant_identifier_names void ChangePassword(String profile, String pass, String newpass, String newpassAgain); + // ignore: non_constant_identifier_names + void ExportProfile(String profile, String file); + // ignore: non_constant_identifier_names + Future ImportProfile(String file, String pass); + // ignore: non_constant_identifier_names void ResetTor(); diff --git a/lib/cwtch/ffi.dart b/lib/cwtch/ffi.dart index c5e56206..a1c8fe2e 100644 --- a/lib/cwtch/ffi.dart +++ b/lib/cwtch/ffi.dart @@ -52,6 +52,9 @@ typedef StringFn = void Function(Pointer dir, int); typedef string_string_to_void_function = Void Function(Pointer str, Int32 length, Pointer str2, Int32 length2); typedef StringStringFn = void Function(Pointer, int, Pointer, int); +typedef string_string_to_string_function = Pointer Function(Pointer str, Int32 length, Pointer str2, Int32 length2); +typedef StringFromStringStringFn = Pointer Function(Pointer, int, Pointer, int); + typedef string_int_to_void_function = Void Function(Pointer str, Int32 length, Int32 handle); typedef VoidFromStringIntFn = void Function(Pointer, int, int); @@ -756,4 +759,33 @@ class CwtchFfi implements Cwtch { cwtchNotifier.l10nInit(notificationSimple, notificationConversationInfo); _isL10nInit = true; } + + @override + // ignore: non_constant_identifier_names + void ExportProfile(String profile, String file) { + final utf8profile = profile.toNativeUtf8(); + final utf8file = file.toNativeUtf8(); + var exportProfileC = library.lookup>("c_ExportProfile"); + // ignore: non_constant_identifier_names + final ExportProfileFn = exportProfileC.asFunction(); + ExportProfileFn(utf8profile, utf8profile.length, utf8file, utf8file.length); + malloc.free(utf8profile); + malloc.free(utf8file); + } + + @override + // ignore: non_constant_identifier_names + Future ImportProfile(String file, String pass) async { + final utf8pass = pass.toNativeUtf8(); + final utf8file = file.toNativeUtf8(); + var exportProfileC = library.lookup>("c_ImportProfile"); + // ignore: non_constant_identifier_names + final ExportProfileFn = exportProfileC.asFunction(); + Pointer result = ExportProfileFn(utf8file, utf8file.length, utf8pass, utf8pass.length); + String importResult = result.toDartString(); + _UnsafeFreePointerAnyUseOfThisFunctionMustBeDoubleApproved(result); + malloc.free(utf8pass); + malloc.free(utf8file); + return importResult; + } } diff --git a/lib/cwtch/gomobile.dart b/lib/cwtch/gomobile.dart index 91a08cec..fc3db6f7 100644 --- a/lib/cwtch/gomobile.dart +++ b/lib/cwtch/gomobile.dart @@ -308,4 +308,16 @@ class CwtchGomobile implements Cwtch { cwtchPlatform.invokeMethod("L10nInit", {"notificationSimple": notificationSimple, "notificationConversationInfo": notificationConversationInfo}); _isL10nInit = true; } + + @override + // ignore: non_constant_identifier_names + void ExportProfile(String profile, String file) { + cwtchPlatform.invokeMethod("ExportProfile", {"ProfileOnion": profile, "file": file}); + } + + @override + // ignore: non_constant_identifier_names + Future ImportProfile(String file, String pass) { + return cwtchPlatform.invokeMethod("ImportProfile", {"file": file, "pass": pass}); + } } diff --git a/lib/views/addeditprofileview.dart b/lib/views/addeditprofileview.dart index 68a1f530..7caa717f 100644 --- a/lib/views/addeditprofileview.dart +++ b/lib/views/addeditprofileview.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:cwtch/config.dart'; +import 'package:cwtch/controllers/filesharing.dart'; import 'package:cwtch/cwtch/cwtch.dart'; import 'package:cwtch/models/appstate.dart'; import 'package:cwtch/models/profile.dart'; @@ -286,6 +287,34 @@ class _AddEditProfileViewState extends State { ), ], ), + Visibility( + visible: Provider.of(context, listen: false).onion.isNotEmpty, + child: Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ + SizedBox( + height: 20, + ), + Tooltip( + message: AppLocalizations.of(context)!.exportProfileTooltip, + child: ElevatedButton.icon( + onPressed: () { + if (Platform.isAndroid) { + Provider.of(context, listen: false).cwtch.ExportProfile(ctrlrOnion.value.text, ctrlrOnion.value.text + ".tar.gz"); + final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.fileSavedTo + " " + ctrlrOnion.value.text + ".tar.gz")); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } else { + showCreateFilePicker(context).then((name) { + if (name != null) { + Provider.of(context, listen: false).cwtch.ExportProfile(ctrlrOnion.value.text, name); + final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.fileSavedTo + " " + name)); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + }); + } + }, + icon: Icon(Icons.import_export), + label: Text(AppLocalizations.of(context)!.exportProfile), + )) + ])), Visibility( visible: Provider.of(context, listen: false).onion.isNotEmpty, child: Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ diff --git a/lib/views/profilemgrview.dart b/lib/views/profilemgrview.dart index f22de587..829ba74e 100644 --- a/lib/views/profilemgrview.dart +++ b/lib/views/profilemgrview.dart @@ -1,6 +1,9 @@ import 'dart:convert'; import 'dart:io'; +import 'package:cwtch/constants.dart'; +import 'package:cwtch/controllers/enter_password.dart'; +import 'package:cwtch/controllers/filesharing.dart'; import 'package:cwtch/cwtch_icons_icons.dart'; import 'package:cwtch/models/appstate.dart'; import 'package:cwtch/models/profile.dart'; @@ -67,7 +70,7 @@ class _ProfileMgrViewState extends State { actions: getActions(), ), floatingActionButton: FloatingActionButton( - onPressed: _pushAddProfile, + onPressed: _modalAddImportProfiles, tooltip: AppLocalizations.of(context)!.addNewProfileBtn, child: Icon( Icons.add, @@ -159,8 +162,9 @@ class _ProfileMgrViewState extends State { )); } - void _pushAddProfile({onion: ""}) { - Navigator.of(context).push(MaterialPageRoute( + void _pushAddProfile(bcontext, {onion: ""}) { + Navigator.popUntil(bcontext, (route) => route.isFirst); + Navigator.of(bcontext).push(MaterialPageRoute( builder: (BuildContext context) { return MultiProvider( providers: [ @@ -174,6 +178,70 @@ class _ProfileMgrViewState extends State { )); } + void _modalAddImportProfiles() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext context) { + return Padding( + padding: MediaQuery.of(context).viewInsets, + child: RepaintBoundary( + child: Container( + height: 200, // bespoke value courtesy of the [TextField] docs + child: Center( + child: Padding( + padding: EdgeInsets.all(10.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + Spacer(), + Expanded( + child: ElevatedButton( + child: Text(AppLocalizations.of(context)!.addProfileTitle, semanticsLabel: AppLocalizations.of(context)!.addProfileTitle), + onPressed: () { + _pushAddProfile(context); + }, + )), + Spacer() + ]), + SizedBox( + height: 20, + ), + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + Spacer(), + Expanded( + child: Tooltip( + message: AppLocalizations.of(context)!.importProfileTooltip, + child: ElevatedButton( + child: Text(AppLocalizations.of(context)!.importProfile, semanticsLabel: AppLocalizations.of(context)!.importProfile), + onPressed: () { + // 10GB profiles should be enough for anyone? + showFilePicker(context, MaxGeneralFileSharingSize, (file) { + showPasswordDialog(context, AppLocalizations.of(context)!.importProfile, AppLocalizations.of(context)!.importProfile, (password) { + Navigator.popUntil(context, (route) => route.isFirst); + Provider.of(context, listen: false).cwtch.ImportProfile(file.path, password).then((value) { + if (value == "") { + final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.successfullyImportedProfile.replaceFirst("%profile", file.path))); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } else { + final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.failedToImportProfile)); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + }); + }); + }, () {}, () {}); + }, + ))), + Spacer() + ]), + ], + ))), + ))); + }); + } + void _modalUnlockProfiles() { showModalBottomSheet( context: context, diff --git a/lib/widgets/filebubble.dart b/lib/widgets/filebubble.dart index 0c944c13..44d09d75 100644 --- a/lib/widgets/filebubble.dart +++ b/lib/widgets/filebubble.dart @@ -46,10 +46,10 @@ class FileBubbleState extends State { @override Widget build(BuildContext context) { - var fromMe = Provider.of(context, listen: false).senderHandle == Provider.of(context).onion; + var fromMe = Provider.of(context).senderHandle == Provider.of(context).onion; var flagStarted = Provider.of(context).attributes["file-downloaded"] == "true"; var borderRadiousEh = 15.0; - var showFileSharing = Provider.of(context, listen: false).isExperimentEnabled(FileSharingExperiment); + var showFileSharing = Provider.of(context).isExperimentEnabled(FileSharingExperiment); DateTime messageDate = Provider.of(context).timestamp; var metadata = Provider.of(context);