Merge pull request 'filesharing' (#188) from filesharing into trunk
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
Reviewed-on: #188 Reviewed-by: Dan Ballard <dan@openprivacy.ca>
This commit is contained in:
commit
0c81b4f9d0
|
@ -1 +1 @@
|
|||
v1.2.1-2-ga8e7bba-2021-09-14-21-04
|
||||
v1.3.0-2021-09-30-20-24
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
@ -56,6 +60,7 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) :
|
|||
if (Cwtch.startCwtch(appDir, torPath) != 0.toLong()) return Result.failure()
|
||||
|
||||
Log.i("FlwtchWorker.kt", "startCwtch success, starting coroutine AppbusEvent loop...")
|
||||
val downloadIDs = mutableMapOf<String, Int>()
|
||||
while(true) {
|
||||
val evt = MainActivity.AppbusEvent(Cwtch.getAppBusEvent())
|
||||
if (evt.EventType == "NewMessageFromPeer" || evt.EventType == "NewMessageFromGroup") {
|
||||
|
@ -93,6 +98,63 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) :
|
|||
.build()
|
||||
notificationManager.notify(getNotificationID(data.getString("ProfileOnion"), handle), newNotification)
|
||||
}
|
||||
} else if (evt.EventType == "FileDownloadProgressUpdate") {
|
||||
try {
|
||||
val data = JSONObject(evt.Data);
|
||||
val fileKey = data.getString("FileKey");
|
||||
val title = data.getString("NameSuggestion");
|
||||
val progress = data.getString("Progress").toInt();
|
||||
val progressMax = data.getString("FileSizeInChunks").toInt();
|
||||
if (!downloadIDs.containsKey(fileKey)) {
|
||||
downloadIDs.put(fileKey, downloadIDs.count());
|
||||
}
|
||||
var dlID = downloadIDs.get(fileKey);
|
||||
if (dlID == null) {
|
||||
dlID = 0;
|
||||
}
|
||||
val channelId =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
createDownloadNotificationChannel(fileKey, fileKey)
|
||||
} else {
|
||||
// If earlier version channel ID is not used
|
||||
// https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context)
|
||||
""
|
||||
};
|
||||
val newNotification = NotificationCompat.Builder(applicationContext, channelId)
|
||||
.setOngoing(true)
|
||||
.setContentTitle("Downloading")//todo: translate
|
||||
.setContentText(title)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setProgress(progressMax, progress, false)
|
||||
.setSound(null)
|
||||
//.setSilent(true)
|
||||
.build();
|
||||
notificationManager.notify(dlID, newNotification);
|
||||
} catch (e: Exception) {
|
||||
Log.i("FlwtchWorker->FileDownloadProgressUpdate", e.toString() + " :: " + e.getStackTrace());
|
||||
}
|
||||
} else if (evt.EventType == "FileDownloaded") {
|
||||
Log.i("FlwtchWorker", "file downloaded!");
|
||||
val data = JSONObject(evt.Data);
|
||||
val tempFile = data.getString("TempFile");
|
||||
val fileKey = data.getString("FileKey");
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (downloadIDs.containsKey(fileKey)) {
|
||||
notificationManager.cancel(downloadIDs.get(fileKey)?:0);
|
||||
}
|
||||
}
|
||||
|
||||
Intent().also { intent ->
|
||||
|
@ -157,6 +219,26 @@ 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)
|
||||
}
|
||||
"CheckDownloadStatus" -> {
|
||||
val profile = (a.get("ProfileOnion") as? String) ?: ""
|
||||
val fileKey = (a.get("fileKey") as? String) ?: ""
|
||||
Cwtch.checkDownloadStatus(profile, fileKey)
|
||||
}
|
||||
"SendProfileEvent" -> {
|
||||
val onion = (a.get("onion") as? String) ?: ""
|
||||
val jsonEvent = (a.get("jsonEvent") as? String) ?: ""
|
||||
|
@ -268,6 +350,15 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) :
|
|||
return channelId
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun createDownloadNotificationChannel(channelId: String, channelName: String): String{
|
||||
val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW)
|
||||
chan.lightColor = Color.MAGENTA
|
||||
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
notificationManager.createNotificationChannel(chan)
|
||||
return channelId
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_METHOD = "KEY_METHOD"
|
||||
const val KEY_ARGS = "KEY_ARGS"
|
||||
|
|
|
@ -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,12 @@ 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 = ""
|
||||
|
||||
// handles clicks received from outside the app (ie, notifications)
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
if (notificationClickChannel == null || intent.extras == null) return
|
||||
|
@ -68,6 +82,24 @@ class MainActivity: FlutterActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
// handles return values from the system file picker
|
||||
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();
|
||||
handleCwtch(MethodCall("DownloadFile", mapOf(
|
||||
"ProfileOnion" to this.dlToProfile,
|
||||
"handle" to this.dlToHandle,
|
||||
"filepath" to filePath,
|
||||
"manifestpath" to manifestPath,
|
||||
"filekey" to this.dlToFileKey
|
||||
)), ErrorLogResult(""));//placeholder; this 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"
|
||||
|
||||
}
|
||||
|
|
|
@ -40,6 +40,15 @@ abstract class Cwtch {
|
|||
// ignore: non_constant_identifier_names
|
||||
void SendInvitation(String profile, String handle, String target);
|
||||
|
||||
// ignore: non_constant_identifier_names
|
||||
void ShareFile(String profile, String handle, String filepath);
|
||||
// ignore: non_constant_identifier_names
|
||||
void DownloadFile(String profile, String handle, String filepath, String manifestpath, String filekey);
|
||||
// ignore: non_constant_identifier_names
|
||||
void CreateDownloadableFile(String profile, String handle, String filenameSuggestion, String filekey);
|
||||
// ignore: non_constant_identifier_names
|
||||
void CheckDownloadStatus(String profile, String fileKey);
|
||||
|
||||
// ignore: non_constant_identifier_names
|
||||
void ArchiveConversation(String profile, String handle);
|
||||
// ignore: non_constant_identifier_names
|
||||
|
|
|
@ -182,8 +182,10 @@ class CwtchNotifier {
|
|||
if (contactHandle == null || contactHandle == "") contactHandle = data["GroupID"];
|
||||
profileCN.getProfile(data["Identity"])?.contactList.getContact(contactHandle)!.totalMessages = int.parse(data["Data"]);
|
||||
break;
|
||||
case "SendMessageToPeerError":
|
||||
// Ignore
|
||||
break;
|
||||
case "IndexedFailure":
|
||||
EnvironmentConfig.debugLog("IndexedFailure");
|
||||
var idx = data["Index"];
|
||||
var key = profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(data["RemotePeer"])?.getMessageKey(idx);
|
||||
try {
|
||||
|
@ -323,6 +325,15 @@ class CwtchNotifier {
|
|||
EnvironmentConfig.debugLog("unhandled peer attribute event: ${data['Path']}");
|
||||
}
|
||||
break;
|
||||
case "ManifestSaved":
|
||||
profileCN.getProfile(data["ProfileOnion"])?.downloadMarkManifest(data["FileKey"]);
|
||||
break;
|
||||
case "FileDownloadProgressUpdate":
|
||||
profileCN.getProfile(data["ProfileOnion"])?.downloadUpdate(data["FileKey"], int.parse(data["Progress"]));
|
||||
break;
|
||||
case "FileDownloaded":
|
||||
profileCN.getProfile(data["ProfileOnion"])?.downloadMarkFinished(data["FileKey"], data["FilePath"]);
|
||||
break;
|
||||
default:
|
||||
EnvironmentConfig.debugLog("unhandled event: $type");
|
||||
}
|
||||
|
|
|
@ -36,6 +36,9 @@ typedef VoidFromStringStringStringFn = void Function(Pointer<Utf8>, int, Pointer
|
|||
typedef void_from_string_string_string_string_function = Void Function(Pointer<Utf8>, Int32, Pointer<Utf8>, Int32, Pointer<Utf8>, Int32, Pointer<Utf8>, Int32);
|
||||
typedef VoidFromStringStringStringStringFn = void Function(Pointer<Utf8>, int, Pointer<Utf8>, int, Pointer<Utf8>, int, Pointer<Utf8>, int);
|
||||
|
||||
typedef void_from_string_string_string_string_string_function = Void Function(Pointer<Utf8>, Int32, Pointer<Utf8>, Int32, Pointer<Utf8>, Int32, Pointer<Utf8>, Int32, Pointer<Utf8>, Int32);
|
||||
typedef VoidFromStringStringStringStringStringFn = void Function(Pointer<Utf8>, int, Pointer<Utf8>, int, Pointer<Utf8>, int, Pointer<Utf8>, int, Pointer<Utf8>, int);
|
||||
|
||||
typedef void_from_string_string_int_int_function = Void Function(Pointer<Utf8>, Int32, Pointer<Utf8>, Int32, Int64, Int64);
|
||||
typedef VoidFromStringStringIntIntFn = void Function(Pointer<Utf8>, int, Pointer<Utf8>, int, int, int);
|
||||
|
||||
|
@ -354,6 +357,59 @@ class CwtchFfi implements Cwtch {
|
|||
malloc.free(u3);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: non_constant_identifier_names
|
||||
void ShareFile(String profileOnion, String contactHandle, String filepath) {
|
||||
var shareFile = library.lookup<NativeFunction<void_from_string_string_string_function>>("c_ShareFile");
|
||||
// ignore: non_constant_identifier_names
|
||||
final ShareFile = shareFile.asFunction<VoidFromStringStringStringFn>();
|
||||
final u1 = profileOnion.toNativeUtf8();
|
||||
final u2 = contactHandle.toNativeUtf8();
|
||||
final u3 = filepath.toNativeUtf8();
|
||||
ShareFile(u1, u1.length, u2, u2.length, u3, u3.length);
|
||||
malloc.free(u1);
|
||||
malloc.free(u2);
|
||||
malloc.free(u3);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: non_constant_identifier_names
|
||||
void DownloadFile(String profileOnion, String contactHandle, String filepath, String manifestpath, String filekey) {
|
||||
var dlFile = library.lookup<NativeFunction<void_from_string_string_string_string_string_function>>("c_DownloadFile");
|
||||
// ignore: non_constant_identifier_names
|
||||
final DownloadFile = dlFile.asFunction<VoidFromStringStringStringStringStringFn>();
|
||||
final u1 = profileOnion.toNativeUtf8();
|
||||
final u2 = contactHandle.toNativeUtf8();
|
||||
final u3 = filepath.toNativeUtf8();
|
||||
final u4 = manifestpath.toNativeUtf8();
|
||||
final u5 = filekey.toNativeUtf8();
|
||||
DownloadFile(u1, u1.length, u2, u2.length, u3, u3.length, u4, u4.length, u5, u5.length);
|
||||
malloc.free(u1);
|
||||
malloc.free(u2);
|
||||
malloc.free(u3);
|
||||
malloc.free(u4);
|
||||
malloc.free(u5);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: non_constant_identifier_names
|
||||
void CreateDownloadableFile(String profileOnion, String contactHandle, String filenameSuggestion, String filekey) {
|
||||
// android only - do nothing
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: non_constant_identifier_names
|
||||
void CheckDownloadStatus(String profileOnion, String fileKey) {
|
||||
var checkDownloadStatus = library.lookup<NativeFunction<string_string_to_void_function>>("c_CheckDownloadStatus");
|
||||
// ignore: non_constant_identifier_names
|
||||
final CheckDownloadStatus = checkDownloadStatus.asFunction<VoidFromStringStringFn>();
|
||||
final u1 = profileOnion.toNativeUtf8();
|
||||
final u2 = fileKey.toNativeUtf8();
|
||||
CheckDownloadStatus(u1, u1.length, u2, u2.length);
|
||||
malloc.free(u1);
|
||||
malloc.free(u2);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: non_constant_identifier_names
|
||||
void ResetTor() {
|
||||
|
|
|
@ -131,6 +131,29 @@ class CwtchGomobile implements Cwtch {
|
|||
cwtchPlatform.invokeMethod("SendInvitation", {"ProfileOnion": profileOnion, "handle": contactHandle, "target": target});
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: non_constant_identifier_names
|
||||
void ShareFile(String profileOnion, String contactHandle, String filepath) {
|
||||
cwtchPlatform.invokeMethod("ShareFile", {"ProfileOnion": profileOnion, "handle": contactHandle, "filepath": filepath});
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: non_constant_identifier_names
|
||||
void DownloadFile(String profileOnion, String contactHandle, String filepath, String manifestpath, String filekey) {
|
||||
cwtchPlatform.invokeMethod("DownloadFile", {"ProfileOnion": profileOnion, "handle": contactHandle, "filepath": filepath, "manifestpath": manifestpath, "filekey": filekey});
|
||||
}
|
||||
|
||||
// ignore: non_constant_identifier_names
|
||||
void CreateDownloadableFile(String profileOnion, String contactHandle, String filenameSuggestion, String filekey) {
|
||||
cwtchPlatform.invokeMethod("CreateDownloadableFile", {"ProfileOnion": profileOnion, "handle": contactHandle, "filename": filenameSuggestion, "filekey": filekey});
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: non_constant_identifier_names
|
||||
void CheckDownloadStatus(String profileOnion, String fileKey) {
|
||||
cwtchPlatform.invokeMethod("CheckDownloadStatus", {"ProfileOnion": profileOnion, "fileKey": fileKey});
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: non_constant_identifier_names
|
||||
void ResetTor() {
|
||||
|
@ -175,7 +198,7 @@ class CwtchGomobile implements Cwtch {
|
|||
@override
|
||||
void UpdateMessageFlags(String profile, String handle, int index, int flags) {
|
||||
print("gomobile.dart UpdateMessageFlags " + index.toString());
|
||||
cwtchPlatform.invokeMethod("UpdateMessageFlags", {"profile": profile, "contact": handle, "index": index, "flags": flags});
|
||||
cwtchPlatform.invokeMethod("UpdateMessageFlags", {"profile": profile, "contact": handle, "midx": index, "flags": flags});
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -213,6 +213,7 @@ class ProfileInfoState extends ChangeNotifier {
|
|||
String _imagePath = "";
|
||||
int _unreadMessages = 0;
|
||||
bool _online = false;
|
||||
Map<String, FileDownloadProgress> _downloads = Map<String, FileDownloadProgress>();
|
||||
|
||||
// assume profiles are encrypted...this will be set to false
|
||||
// in the constructor if the profile is encrypted with the defacto password.
|
||||
|
@ -356,6 +357,100 @@ class ProfileInfoState extends ChangeNotifier {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
void downloadInit(String fileKey, int numChunks) {
|
||||
this._downloads[fileKey] = FileDownloadProgress(numChunks, DateTime.now());
|
||||
}
|
||||
|
||||
void downloadUpdate(String fileKey, int progress) {
|
||||
if (!downloadActive(fileKey)) {
|
||||
print("error: received progress for unknown download " + fileKey);
|
||||
} else {
|
||||
this._downloads[fileKey]!.chunksDownloaded = progress;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void downloadMarkManifest(String fileKey) {
|
||||
if (!downloadActive(fileKey)) {
|
||||
print("error: received download completion notice for unknown download " + fileKey);
|
||||
} else {
|
||||
this._downloads[fileKey]!.gotManifest = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void downloadMarkFinished(String fileKey, String finalPath) {
|
||||
if (!downloadActive(fileKey)) {
|
||||
// happens as a result of a CheckDownloadStatus call,
|
||||
// invoked from a historical (timeline) download message
|
||||
// so setting numChunks correctly shouldn't matter
|
||||
this.downloadInit(fileKey, 1);
|
||||
}
|
||||
this._downloads[fileKey]!.timeEnd = DateTime.now();
|
||||
this._downloads[fileKey]!.downloadedTo = finalPath;
|
||||
this._downloads[fileKey]!.complete = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool downloadActive(String fileKey) {
|
||||
return this._downloads.containsKey(fileKey);
|
||||
}
|
||||
|
||||
bool downloadGotManifest(String fileKey) {
|
||||
return this._downloads.containsKey(fileKey) && this._downloads[fileKey]!.gotManifest;
|
||||
}
|
||||
|
||||
bool downloadComplete(String fileKey) {
|
||||
return this._downloads.containsKey(fileKey) && this._downloads[fileKey]!.complete;
|
||||
}
|
||||
|
||||
double downloadProgress(String fileKey) {
|
||||
return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.progress() : 0.0;
|
||||
}
|
||||
|
||||
String? downloadFinalPath(String fileKey) {
|
||||
return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.downloadedTo : null;
|
||||
}
|
||||
|
||||
String downloadSpeed(String fileKey) {
|
||||
if (!downloadActive(fileKey) || this._downloads[fileKey]!.chunksDownloaded == 0) {
|
||||
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 {
|
||||
int chunksDownloaded = 0;
|
||||
int chunksTotal = 1;
|
||||
bool complete = false;
|
||||
bool gotManifest = false;
|
||||
String? downloadedTo;
|
||||
DateTime? timeStart;
|
||||
DateTime? timeEnd;
|
||||
|
||||
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 }
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
import '../main.dart';
|
||||
import '../model.dart';
|
||||
import 'messages/filemessage.dart';
|
||||
import 'messages/invitemessage.dart';
|
||||
import 'messages/malformedmessage.dart';
|
||||
import 'messages/quotedmessage.dart';
|
||||
|
@ -14,6 +15,7 @@ const TextMessageOverlay = 1;
|
|||
const QuotedMessageOverlay = 10;
|
||||
const SuggestContactOverlay = 100;
|
||||
const InviteGroupOverlay = 101;
|
||||
const FileShareOverlay = 200;
|
||||
|
||||
// Defines the length of the tor v3 onion address. Code using this constant will
|
||||
// need to updated when we allow multiple different identifiers. At which time
|
||||
|
@ -33,38 +35,39 @@ Future<Message> messageHandler(BuildContext context, String profileOnion, String
|
|||
try {
|
||||
var rawMessageEnvelopeFuture = Provider.of<FlwtchState>(context, listen: false).cwtch.GetMessage(profileOnion, contactHandle, index);
|
||||
return rawMessageEnvelopeFuture.then((dynamic rawMessageEnvelope) {
|
||||
dynamic messageWrapper = jsonDecode(rawMessageEnvelope);
|
||||
// There are 2 conditions in which this error condition can be met:
|
||||
// 1. The application == nil, in which case this instance of the UI is already
|
||||
// broken beyond repair, and will either be replaced by a new version, or requires a complete
|
||||
// restart.
|
||||
// 2. This index was incremented and we happened to fetch the timeline prior to the messages inclusion.
|
||||
// This should be rare as Timeline addition/fetching is mutex protected and Dart itself will pipeline the
|
||||
// calls to libCwtch-go - however because we use goroutines on the backend there is always a chance that one
|
||||
// will find itself delayed.
|
||||
// The second case is recoverable by tail-recursing this future.
|
||||
if (messageWrapper['Message'] == null || messageWrapper['Message'] == '' || messageWrapper['Message'] == '{}') {
|
||||
return Future.delayed(Duration(seconds: 2), () {
|
||||
print("Tail recursive call to messageHandler called. This should be a rare event. If you see multiples of this log over a short period of time please log it as a bug.");
|
||||
return messageHandler(context, profileOnion, contactHandle, index).then((value) => value);
|
||||
});
|
||||
}
|
||||
|
||||
// Construct the initial metadata
|
||||
var timestamp = DateTime.tryParse(messageWrapper['Timestamp'])!;
|
||||
var senderHandle = messageWrapper['PeerID'];
|
||||
var senderImage = messageWrapper['ContactImage'];
|
||||
var flags = int.parse(messageWrapper['Flags'].toString(), radix: 2);
|
||||
var ackd = messageWrapper['Acknowledged'];
|
||||
var error = messageWrapper['Error'] != null;
|
||||
String? signature;
|
||||
// If this is a group, store the signature
|
||||
if (contactHandle.length == GroupConversationHandleLength) {
|
||||
signature = messageWrapper['Signature'];
|
||||
}
|
||||
var metadata = MessageMetadata(profileOnion, contactHandle, index, timestamp, senderHandle, senderImage, signature, flags, ackd, error);
|
||||
|
||||
var metadata = MessageMetadata(profileOnion, contactHandle, index, DateTime.now(), "", "", null, 0, false, true);
|
||||
try {
|
||||
dynamic messageWrapper = jsonDecode(rawMessageEnvelope);
|
||||
// There are 2 conditions in which this error condition can be met:
|
||||
// 1. The application == nil, in which case this instance of the UI is already
|
||||
// broken beyond repair, and will either be replaced by a new version, or requires a complete
|
||||
// restart.
|
||||
// 2. This index was incremented and we happened to fetch the timeline prior to the messages inclusion.
|
||||
// This should be rare as Timeline addition/fetching is mutex protected and Dart itself will pipeline the
|
||||
// calls to libCwtch-go - however because we use goroutines on the backend there is always a chance that one
|
||||
// will find itself delayed.
|
||||
// The second case is recoverable by tail-recursing this future.
|
||||
if (messageWrapper['Message'] == null || messageWrapper['Message'] == '' || messageWrapper['Message'] == '{}') {
|
||||
return Future.delayed(Duration(seconds: 2), () {
|
||||
print("Tail recursive call to messageHandler called. This should be a rare event. If you see multiples of this log over a short period of time please log it as a bug.");
|
||||
return messageHandler(context, profileOnion, contactHandle, index).then((value) => value);
|
||||
});
|
||||
}
|
||||
|
||||
// Construct the initial metadata
|
||||
var timestamp = DateTime.tryParse(messageWrapper['Timestamp'])!;
|
||||
var senderHandle = messageWrapper['PeerID'];
|
||||
var senderImage = messageWrapper['ContactImage'];
|
||||
var flags = int.parse(messageWrapper['Flags'].toString());
|
||||
var ackd = messageWrapper['Acknowledged'];
|
||||
var error = messageWrapper['Error'] != null;
|
||||
String? signature;
|
||||
// If this is a group, store the signature
|
||||
if (contactHandle.length == GroupConversationHandleLength) {
|
||||
signature = messageWrapper['Signature'];
|
||||
}
|
||||
metadata = MessageMetadata(profileOnion, contactHandle, index, timestamp, senderHandle, senderImage, signature, flags, ackd, error);
|
||||
|
||||
dynamic message = jsonDecode(messageWrapper['Message']);
|
||||
var content = message['d'] as dynamic;
|
||||
var overlay = int.parse(message['o'].toString());
|
||||
|
@ -77,11 +80,14 @@ Future<Message> messageHandler(BuildContext context, String profileOnion, String
|
|||
return InviteMessage(overlay, metadata, content);
|
||||
case QuotedMessageOverlay:
|
||||
return QuotedMessage(metadata, content);
|
||||
case FileShareOverlay:
|
||||
return FileMessage(metadata, content);
|
||||
default:
|
||||
// Metadata is valid, content is not..
|
||||
return MalformedMessage(metadata);
|
||||
}
|
||||
} catch (e) {
|
||||
print("an error! " + e.toString());
|
||||
return MalformedMessage(metadata);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:cwtch/models/message.dart';
|
||||
import 'package:cwtch/widgets/filebubble.dart';
|
||||
import 'package:cwtch/widgets/malformedbubble.dart';
|
||||
import 'package:cwtch/widgets/messagerow.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../model.dart';
|
||||
|
||||
class FileMessage extends Message {
|
||||
final MessageMetadata metadata;
|
||||
final String content;
|
||||
|
||||
FileMessage(this.metadata, this.content);
|
||||
|
||||
@override
|
||||
Widget getWidget(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: this.metadata,
|
||||
builder: (bcontext, child) {
|
||||
String idx = Provider.of<ContactInfoState>(context).isGroup == true && this.metadata.signature != null ? this.metadata.signature! : this.metadata.messageIndex.toString();
|
||||
dynamic shareObj = jsonDecode(this.content);
|
||||
if (shareObj == null) {
|
||||
return MessageRow(MalformedBubble());
|
||||
}
|
||||
String nameSuggestion = shareObj['f'] as String;
|
||||
String rootHash = shareObj['h'] as String;
|
||||
String nonce = shareObj['n'] as String;
|
||||
int fileSize = shareObj['s'] as int;
|
||||
|
||||
return MessageRow(FileBubble(nameSuggestion, rootHash, nonce, fileSize), key: Provider.of<ContactInfoState>(bcontext).getMessageKey(idx));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget getPreviewWidget(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: this.metadata,
|
||||
builder: (bcontext, child) {
|
||||
dynamic shareObj = jsonDecode(this.content);
|
||||
if (shareObj == null) {
|
||||
return MessageRow(MalformedBubble());
|
||||
}
|
||||
String nameSuggestion = shareObj['n'] as String;
|
||||
String rootHash = shareObj['h'] as String;
|
||||
String nonce = shareObj['n'] as String;
|
||||
int fileSize = shareObj['s'] as int;
|
||||
return FileBubble(
|
||||
nameSuggestion,
|
||||
rootHash,
|
||||
nonce,
|
||||
fileSize,
|
||||
interactive: false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
MessageMetadata getMetadata() {
|
||||
return this.metadata;
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ import 'opaque.dart';
|
|||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
const TapirGroupsExperiment = "tapir-groups-experiment";
|
||||
const FileSharingExperiment = "filesharing";
|
||||
|
||||
enum DualpaneMode {
|
||||
Single,
|
||||
|
|
|
@ -188,6 +188,22 @@ class _GlobalSettingsViewState extends State<GlobalSettingsView> {
|
|||
inactiveTrackColor: settings.theme.defaultButtonDisabledColor(),
|
||||
secondary: Icon(CwtchIcons.enable_groups, color: settings.current().mainTextColor()),
|
||||
),
|
||||
SwitchListTile(
|
||||
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) {
|
||||
if (value) {
|
||||
settings.enableExperiment(FileSharingExperiment);
|
||||
} else {
|
||||
settings.disableExperiment(FileSharingExperiment);
|
||||
}
|
||||
saveSettings(context);
|
||||
},
|
||||
activeTrackColor: settings.theme.defaultButtonActiveColor(),
|
||||
inactiveTrackColor: settings.theme.defaultButtonDisabledColor(),
|
||||
secondary: Icon(Icons.attach_file, color: settings.current().mainTextColor()),
|
||||
),
|
||||
],
|
||||
)),
|
||||
AboutListTile(
|
||||
|
|
|
@ -6,6 +6,9 @@ import 'package:cwtch/models/message.dart';
|
|||
import 'package:cwtch/models/messages/quotedmessage.dart';
|
||||
import 'package:cwtch/widgets/messageloadingbubble.dart';
|
||||
import 'package:cwtch/widgets/profileimage.dart';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cwtch/views/peersettingsview.dart';
|
||||
|
@ -14,6 +17,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'package:path/path.dart' show basename;
|
||||
|
||||
import '../main.dart';
|
||||
import '../model.dart';
|
||||
|
@ -36,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]
|
||||
|
@ -74,6 +81,25 @@ class _MessageViewState extends State<MessageView> {
|
|||
return Card(child: Center(child: Text(AppLocalizations.of(context)!.addContactFirst)));
|
||||
}
|
||||
|
||||
var appBarButtons = <Widget>[];
|
||||
if (Provider.of<ContactInfoState>(context).isOnline()) {
|
||||
appBarButtons.add(IconButton(
|
||||
icon: Icon(Icons.attach_file, size: 24),
|
||||
tooltip: AppLocalizations.of(context)!.tooltipSendFile,
|
||||
onPressed: _showFilePicker,
|
||||
));
|
||||
appBarButtons.add(IconButton(
|
||||
icon: Icon(CwtchIcons.send_invite, size: 24),
|
||||
tooltip: AppLocalizations.of(context)!.sendInvite,
|
||||
onPressed: () {
|
||||
_modalSendInvitation(context);
|
||||
}));
|
||||
}
|
||||
appBarButtons.add(IconButton(
|
||||
icon: Provider.of<ContactInfoState>(context, listen: false).isGroup == true ? Icon(CwtchIcons.group_settings_24px) : Icon(CwtchIcons.peer_settings_24px),
|
||||
tooltip: AppLocalizations.of(context)!.conversationSettings,
|
||||
onPressed: _pushContactSettings));
|
||||
|
||||
var appState = Provider.of<AppState>(context);
|
||||
return WillPopScope(
|
||||
onWillPop: _onWillPop,
|
||||
|
@ -105,21 +131,7 @@ class _MessageViewState extends State<MessageView> {
|
|||
overflow: TextOverflow.ellipsis,
|
||||
))
|
||||
]),
|
||||
actions: [
|
||||
//IconButton(icon: Icon(Icons.chat), onPressed: _pushContactSettings),
|
||||
//IconButton(icon: Icon(Icons.list), onPressed: _pushContactSettings),
|
||||
//IconButton(icon: Icon(Icons.push_pin), onPressed: _pushContactSettings),
|
||||
IconButton(
|
||||
icon: Icon(CwtchIcons.send_invite, size: 24),
|
||||
tooltip: AppLocalizations.of(context)!.sendInvite,
|
||||
onPressed: () {
|
||||
_modalSendInvitation(context);
|
||||
}),
|
||||
IconButton(
|
||||
icon: Provider.of<ContactInfoState>(context, listen: false).isGroup == true ? Icon(CwtchIcons.group_settings_24px) : Icon(CwtchIcons.peer_settings_24px),
|
||||
tooltip: AppLocalizations.of(context)!.conversationSettings,
|
||||
onPressed: _pushContactSettings),
|
||||
],
|
||||
actions: appBarButtons,
|
||||
),
|
||||
body: Padding(padding: EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 108.0), child: MessageList(scrollController, scrollListener)),
|
||||
bottomSheet: _buildComposeBox(),
|
||||
|
@ -189,6 +201,13 @@ class _MessageViewState extends State<MessageView> {
|
|||
_sendMessageHelper();
|
||||
}
|
||||
|
||||
void _sendFile(String filePath) {
|
||||
Provider.of<FlwtchState>(context, listen: false)
|
||||
.cwtch
|
||||
.ShareFile(Provider.of<ContactInfoState>(context, listen: false).profileOnion, Provider.of<ContactInfoState>(context, listen: false).onion, filePath);
|
||||
_sendMessageHelper();
|
||||
}
|
||||
|
||||
void _sendMessageHelper() {
|
||||
ctrlrCompose.clear();
|
||||
focusNode.requestFocus();
|
||||
|
@ -351,4 +370,20 @@ class _MessageViewState extends State<MessageView> {
|
|||
));
|
||||
});
|
||||
}
|
||||
|
||||
void _showFilePicker() async {
|
||||
FilePickerResult? result = await FilePicker.platform.pickFiles();
|
||||
if (result != null) {
|
||||
File file = File(result.files.first.path);
|
||||
// We have a maximum number of bytes we can represent in terms of
|
||||
// a manifest (see : https://git.openprivacy.ca/cwtch.im/cwtch/src/branch/master/protocol/files/manifest.go#L25)
|
||||
if (file.lengthSync() <= 10737418240) {
|
||||
print("Sending " + file.path);
|
||||
_sendFile(file.path);
|
||||
} else {
|
||||
print("file size cannot exceed 10 gigabytes");
|
||||
//todo: toast error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,277 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:cwtch/models/message.dart';
|
||||
import 'package:file_picker_desktop/file_picker_desktop.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../main.dart';
|
||||
import '../model.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../settings.dart';
|
||||
import 'messagebubbledecorations.dart';
|
||||
|
||||
// Like MessageBubble but for displaying chat overlay 100/101 invitations
|
||||
// Offers the user an accept/reject button if they don't have a matching contact already
|
||||
class FileBubble extends StatefulWidget {
|
||||
final String nameSuggestion;
|
||||
final String rootHash;
|
||||
final String nonce;
|
||||
final int fileSize;
|
||||
final bool interactive;
|
||||
|
||||
FileBubble(this.nameSuggestion, this.rootHash, this.nonce, this.fileSize, {this.interactive = true});
|
||||
|
||||
@override
|
||||
FileBubbleState createState() => FileBubbleState();
|
||||
|
||||
String fileKey() {
|
||||
return this.rootHash + "." + this.nonce;
|
||||
}
|
||||
}
|
||||
|
||||
class FileBubbleState extends State<FileBubble> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var fromMe = Provider.of<MessageMetadata>(context).senderHandle == Provider.of<ProfileInfoState>(context).onion;
|
||||
var flagStarted = Provider.of<MessageMetadata>(context).flags & 0x02 > 0;
|
||||
var borderRadiousEh = 15.0;
|
||||
var showFileSharing = Provider.of<Settings>(context).isExperimentEnabled(FileSharingExperiment);
|
||||
var prettyDate = DateFormat.yMd(Platform.localeName).add_jm().format(Provider.of<MessageMetadata>(context).timestamp);
|
||||
|
||||
// If the sender is not us, then we want to give them a nickname...
|
||||
var senderDisplayStr = "";
|
||||
if (!fromMe) {
|
||||
ContactInfoState? contact = Provider.of<ProfileInfoState>(context).contactList.getContact(Provider.of<MessageMetadata>(context).senderHandle);
|
||||
if (contact != null) {
|
||||
senderDisplayStr = contact.nickname;
|
||||
} else {
|
||||
senderDisplayStr = Provider.of<MessageMetadata>(context).senderHandle;
|
||||
}
|
||||
}
|
||||
|
||||
var wdgSender = Center(
|
||||
widthFactor: 1,
|
||||
child: SelectableText(senderDisplayStr + '\u202F',
|
||||
style: TextStyle(fontSize: 9.0, color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeTextColor() : Provider.of<Settings>(context).theme.messageFromOtherTextColor())));
|
||||
|
||||
var wdgMessage = !showFileSharing
|
||||
? Text(AppLocalizations.of(context)!.messageEnableFileSharing)
|
||||
: fromMe
|
||||
? senderFileChrome(AppLocalizations.of(context)!.messageFileSent, 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) {
|
||||
wdgDecorations = Text('\u202F');
|
||||
} else if (fromMe) {
|
||||
wdgDecorations = MessageBubbleDecoration(ackd: Provider.of<MessageMetadata>(context).ackd, errored: Provider.of<MessageMetadata>(context).error, fromMe: fromMe, prettyDate: prettyDate);
|
||||
} else if (Provider.of<ProfileInfoState>(context).downloadComplete(widget.fileKey())) {
|
||||
// in this case, whatever marked download.complete would have also set the path
|
||||
var path = Provider.of<ProfileInfoState>(context).downloadFinalPath(widget.fileKey())!;
|
||||
wdgDecorations = Text('Saved to: ' + path + '\u202F');
|
||||
} else if (Provider.of<ProfileInfoState>(context).downloadActive(widget.fileKey())) {
|
||||
if (!Provider.of<ProfileInfoState>(context).downloadGotManifest(widget.fileKey())) {
|
||||
wdgDecorations = Text(AppLocalizations.of(context)!.retrievingManifestMessage + '\u202F');
|
||||
} else {
|
||||
wdgDecorations = LinearProgressIndicator(
|
||||
value: Provider.of<ProfileInfoState>(context).downloadProgress(widget.fileKey()),
|
||||
color: Provider.of<Settings>(context).theme.defaultButtonActiveColor(),
|
||||
);
|
||||
}
|
||||
} else if (flagStarted) {
|
||||
// in this case, the download was done in a previous application launch,
|
||||
// so we probably have to request an info lookup
|
||||
var path = Provider.of<ProfileInfoState>(context).downloadFinalPath(widget.fileKey());
|
||||
if (path == null) {
|
||||
wdgDecorations = Text('Checking download status...' + '\u202F');
|
||||
Provider.of<FlwtchState>(context, listen: false).cwtch.CheckDownloadStatus(Provider.of<ProfileInfoState>(context, listen: false).onion, widget.fileKey());
|
||||
} else {
|
||||
wdgDecorations = Text('Saved to: ' + path + '\u202F');
|
||||
}
|
||||
} else {
|
||||
wdgDecorations = Center(
|
||||
widthFactor: 1,
|
||||
child: Wrap(children: [
|
||||
Padding(padding: EdgeInsets.all(5), child: ElevatedButton(child: Text(AppLocalizations.of(context)!.downloadFileButton + '\u202F'), onPressed: _btnAccept)),
|
||||
]));
|
||||
}
|
||||
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
//print(constraints.toString()+", "+constraints.maxWidth.toString());
|
||||
return Center(
|
||||
widthFactor: 1.0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeBackgroundColor() : Provider.of<Settings>(context).theme.messageFromOtherBackgroundColor(),
|
||||
border:
|
||||
Border.all(color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeBackgroundColor() : Provider.of<Settings>(context).theme.messageFromOtherBackgroundColor(), width: 1),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(borderRadiousEh),
|
||||
topRight: Radius.circular(borderRadiousEh),
|
||||
bottomLeft: fromMe ? Radius.circular(borderRadiousEh) : Radius.zero,
|
||||
bottomRight: fromMe ? Radius.zero : Radius.circular(borderRadiousEh),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
widthFactor: 1.0,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(9.0),
|
||||
child: Wrap(alignment: WrapAlignment.start, children: [
|
||||
Center(
|
||||
widthFactor: 1.0,
|
||||
child: Column(
|
||||
crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
mainAxisAlignment: fromMe ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: fromMe
|
||||
? [wdgMessage, Visibility(visible: widget.interactive, child: wdgDecorations)]
|
||||
: [wdgSender, wdgMessage, Visibility(visible: widget.interactive, child: wdgDecorations)]),
|
||||
)
|
||||
])))));
|
||||
});
|
||||
}
|
||||
|
||||
void _btnAccept() async {
|
||||
String? selectedFileName;
|
||||
File? file;
|
||||
var profileOnion = Provider.of<ProfileInfoState>(context, listen: false).onion;
|
||||
var handle = Provider.of<MessageMetadata>(context, listen: false).senderHandle;
|
||||
var contact = Provider.of<ContactInfoState>(context, listen: false).onion;
|
||||
var idx = Provider.of<MessageMetadata>(context, listen: false).messageIndex;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
Provider.of<ProfileInfoState>(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil());
|
||||
Provider.of<FlwtchState>(context, listen: false).cwtch.UpdateMessageFlags(profileOnion, contact, idx, Provider.of<MessageMetadata>(context, listen: false).flags | 0x02);
|
||||
Provider.of<MessageMetadata>(context, listen: false).flags |= 0x02;
|
||||
Provider.of<FlwtchState>(context, listen: false).cwtch.CreateDownloadableFile(profileOnion, handle, widget.nameSuggestion, widget.fileKey());
|
||||
} else {
|
||||
try {
|
||||
selectedFileName = await saveFile(
|
||||
defaultFileName: widget.nameSuggestion,
|
||||
);
|
||||
if (selectedFileName != null) {
|
||||
file = File(selectedFileName);
|
||||
print("saving to " + file.path);
|
||||
var manifestPath = file.path + ".manifest";
|
||||
Provider.of<ProfileInfoState>(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil());
|
||||
Provider.of<FlwtchState>(context, listen: false).cwtch.UpdateMessageFlags(profileOnion, contact, idx, Provider.of<MessageMetadata>(context, listen: false).flags | 0x02);
|
||||
Provider.of<MessageMetadata>(context, listen: false).flags |= 0x02;
|
||||
Provider.of<FlwtchState>(context, listen: false).cwtch.DownloadFile(profileOnion, handle, file.path, manifestPath, widget.fileKey());
|
||||
}
|
||||
} catch (e) {
|
||||
print(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Construct an file chrome for the sender
|
||||
Widget senderFileChrome(String chrome, String fileName, String rootHash, int fileSize) {
|
||||
return ListTile(
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: Wrap(direction: Axis.horizontal, alignment: WrapAlignment.start, children: [
|
||||
SelectableText(
|
||||
chrome + '\u202F',
|
||||
style: TextStyle(
|
||||
color: Provider.of<Settings>(context).theme.messageFromMeTextColor(),
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
maxLines: 2,
|
||||
textWidthBasis: TextWidthBasis.longestLine,
|
||||
),
|
||||
SelectableText(
|
||||
fileName + '\u202F',
|
||||
style: TextStyle(
|
||||
color: Provider.of<Settings>(context).theme.messageFromMeTextColor(),
|
||||
fontWeight: FontWeight.bold,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
textWidthBasis: TextWidthBasis.parent,
|
||||
maxLines: 2,
|
||||
),
|
||||
SelectableText(
|
||||
prettyBytes(fileSize) + '\u202F' + '\n',
|
||||
style: TextStyle(
|
||||
color: Provider.of<Settings>(context).theme.messageFromMeTextColor(),
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
maxLines: 2,
|
||||
)
|
||||
]),
|
||||
subtitle: SelectableText(
|
||||
'sha512: ' + rootHash + '\u202F',
|
||||
style: TextStyle(
|
||||
color: Provider.of<Settings>(context).theme.messageFromMeTextColor(),
|
||||
fontSize: 10,
|
||||
fontFamily: "monospace",
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
maxLines: 4,
|
||||
textWidthBasis: TextWidthBasis.parent,
|
||||
),
|
||||
leading: Icon(Icons.attach_file, size: 32, color: Provider.of<Settings>(context).theme.messageFromMeTextColor()));
|
||||
}
|
||||
|
||||
// Construct an file chrome
|
||||
Widget fileChrome(String chrome, String fileName, String rootHash, int fileSize, String speed) {
|
||||
return ListTile(
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: Wrap(direction: Axis.horizontal, alignment: WrapAlignment.start, children: [
|
||||
SelectableText(
|
||||
chrome + '\u202F',
|
||||
style: TextStyle(
|
||||
color: Provider.of<Settings>(context).theme.messageFromOtherTextColor(),
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
maxLines: 2,
|
||||
textWidthBasis: TextWidthBasis.longestLine,
|
||||
),
|
||||
SelectableText(
|
||||
fileName + '\u202F',
|
||||
style: TextStyle(
|
||||
color: Provider.of<Settings>(context).theme.messageFromOtherTextColor(),
|
||||
fontWeight: FontWeight.bold,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
textWidthBasis: TextWidthBasis.parent,
|
||||
maxLines: 2,
|
||||
),
|
||||
SelectableText(
|
||||
AppLocalizations.of(context)!.labelFilesize + ': ' + prettyBytes(fileSize) + '\u202F' + '\n',
|
||||
style: TextStyle(
|
||||
color: Provider.of<Settings>(context).theme.messageFromOtherTextColor(),
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
maxLines: 2,
|
||||
)
|
||||
]),
|
||||
subtitle: SelectableText(
|
||||
'sha512: ' + rootHash + '\u202F',
|
||||
style: TextStyle(
|
||||
color: Provider.of<Settings>(context).theme.messageFromMeTextColor(),
|
||||
fontSize: 10,
|
||||
fontFamily: "monospace",
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
maxLines: 4,
|
||||
textWidthBasis: TextWidthBasis.parent,
|
||||
),
|
||||
leading: Icon(Icons.attach_file, size: 32, color: Provider.of<Settings>(context).theme.messageFromOtherTextColor()),
|
||||
trailing: Visibility(
|
||||
visible: speed != "0 B/s",
|
||||
child: SelectableText(
|
||||
speed + '\u202F',
|
||||
style: TextStyle(
|
||||
color: Provider.of<Settings>(context).theme.messageFromMeTextColor(),
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
maxLines: 1,
|
||||
textWidthBasis: TextWidthBasis.longestLine,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -131,7 +131,7 @@ class InvitationBubbleState extends State<InvitationBubble> {
|
|||
var contact = Provider.of<ContactInfoState>(context, listen: false).onion;
|
||||
var idx = Provider.of<MessageMetadata>(context, listen: false).messageIndex;
|
||||
Provider.of<FlwtchState>(context, listen: false).cwtch.UpdateMessageFlags(profileOnion, contact, idx, Provider.of<MessageMetadata>(context, listen: false).flags | 0x01);
|
||||
Provider.of<MessageMetadata>(context).flags |= 0x01;
|
||||
Provider.of<MessageMetadata>(context, listen: false).flags |= 0x01;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -87,7 +87,7 @@ class _MessageListState extends State<MessageList> {
|
|||
// Already includes MessageRow,,
|
||||
return message.getWidget(context);
|
||||
} else {
|
||||
return MessageLoadingBubble();
|
||||
return Text(''); //MessageLoadingBubble();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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) {
|
||||
|
|
23
pubspec.lock
23
pubspec.lock
|
@ -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
|
||||
|
@ -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