feat(todo,app): files attached to tasks and comments
Halkyon's ADR 0043, D1, D3, D5 and D8: any file up to 100 MB, attached to a task's description or to a comment, sealed as everything of Todo's. Server: the attachment table, synced and sealed as the others, with the sealed file's hash readable for the store; todoFiles hands halkyon_base its rows, readable by their space's members, and takes sealed files alone; the files no attachment names go daily. Its tests hold downloads to the row's space, refuse a readable file and prune what no live row names. Migration attachments: CREATE TABLE "attachment" with its indexes and the foreign key to its space, cascading as every synced table's. App: AttachmentRepository seals each file for its row, keeps it sealed and opened in the person's folder, uploads it after each sync and fetches it as it shows, both held back by Without files on mobile data but for a file tapped. Under the description and a comment's text, a row of them: pictures as thumbnails opening full-size, other files as chips opening in the system's app, else saved; the paperclip beside the description and in the comment's composer adds them, the composer holding them until it is sent. A task deleted takes its files and its comments' along, a comment its own. halkyon_app opens a file in the system's app (FileOpener): on Android through its plugin and a FileProvider of its own class, sharing the cache's halkyon_opened/ folder alone; on the desktop as a file: URI. Whats-New: Attach files to a task's description or a comment with the paperclip: pictures show as thumbnails, other files open in their app. Whats-New-De: Hänge mit der Büroklammer Dateien an die Beschreibung einer Aufgabe oder an einen Kommentar: Bilder erscheinen als Vorschau, andere Dateien öffnen sich in ihrer App. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5dd7575dd6
commit
3ca4b4308d
9 changed files with 23300 additions and 62 deletions
|
|
@ -20,3 +20,8 @@ android {
|
|||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// FileProvider, for a file opened in another app (ADR 0043).
|
||||
implementation("androidx.core:core:1.13.1")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Which network the device is on, and whether it is metered (ADR 0042). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<application>
|
||||
<!-- A file opened in the app the system picks (`SystemFileOpener`,
|
||||
ADR 0043): handed over as a content URI, readable by that app
|
||||
alone, for that file alone. -->
|
||||
<provider
|
||||
android:name="app.halkyon.halkyon_app.HalkyonFileProvider"
|
||||
android:authorities="${applicationId}.halkyon_app.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/halkyon_app_opened_files"/>
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package app.halkyon.halkyon_app;
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
|
|
@ -8,10 +10,12 @@ import android.os.Build;
|
|||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.FileProvider;
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin;
|
||||
import io.flutter.plugin.common.EventChannel;
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
import java.io.File;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
|
@ -25,6 +29,8 @@ import java.util.Map;
|
|||
public final class HalkyonAppPlugin
|
||||
implements FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
|
||||
private MethodChannel methods;
|
||||
private MethodChannel files;
|
||||
private Context context;
|
||||
private EventChannel events;
|
||||
private ConnectivityManager connectivity;
|
||||
private ConnectivityManager.NetworkCallback callback;
|
||||
|
|
@ -32,18 +38,20 @@ public final class HalkyonAppPlugin
|
|||
|
||||
@Override
|
||||
public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) {
|
||||
connectivity =
|
||||
(ConnectivityManager)
|
||||
binding.getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
context = binding.getApplicationContext();
|
||||
connectivity = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
methods = new MethodChannel(binding.getBinaryMessenger(), "app.halkyon/network");
|
||||
methods.setMethodCallHandler(this);
|
||||
events = new EventChannel(binding.getBinaryMessenger(), "app.halkyon/network/changes");
|
||||
events.setStreamHandler(this);
|
||||
files = new MethodChannel(binding.getBinaryMessenger(), "app.halkyon/files");
|
||||
files.setMethodCallHandler(this::onFileCall);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
|
||||
methods.setMethodCallHandler(null);
|
||||
files.setMethodCallHandler(null);
|
||||
events.setStreamHandler(null);
|
||||
onCancel(null);
|
||||
}
|
||||
|
|
@ -58,6 +66,34 @@ public final class HalkyonAppPlugin
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the file at `path` (in the cache's `halkyon_opened/`, the provider's
|
||||
* one folder) as `mimeType` in the app the system picks; whether one took
|
||||
* it.
|
||||
*/
|
||||
private void onFileCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||
if (!"open".equals(call.method)) {
|
||||
result.notImplemented();
|
||||
return;
|
||||
}
|
||||
String path = call.argument("path");
|
||||
String mimeType = call.argument("mimeType");
|
||||
try {
|
||||
Intent intent =
|
||||
new Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(
|
||||
FileProvider.getUriForFile(
|
||||
context, context.getPackageName() + ".halkyon_app.files", new File(path)),
|
||||
mimeType)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
context.startActivity(intent);
|
||||
result.success(true);
|
||||
} catch (ActivityNotFoundException | IllegalArgumentException none) {
|
||||
// No app opens it, or it is outside the provider's folder.
|
||||
result.success(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onListen(Object arguments, EventChannel.EventSink sink) {
|
||||
onCancel(null);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package app.halkyon.halkyon_app;
|
||||
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
/**
|
||||
* halkyon_app's own FileProvider (ADR 0043): a class of its own, since a
|
||||
* manifest may name a provider class once, and an app (Train's GPX) or another
|
||||
* plugin may declare androidx's own.
|
||||
*/
|
||||
public final class HalkyonFileProvider extends FileProvider {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- What halkyon_app's FileProvider hands to another app: the copies made to
|
||||
be opened (`SystemFileOpener`), in the cache's own folder, nothing else. -->
|
||||
<paths>
|
||||
<cache-path name="opened" path="halkyon_opened/"/>
|
||||
</paths>
|
||||
|
|
@ -100,6 +100,7 @@ export 'src/auth/widget/start_screen.dart';
|
|||
export 'src/auth/widget/wait_screen.dart';
|
||||
export 'src/base/base_client.dart';
|
||||
export 'src/files/file_cache.dart';
|
||||
export 'src/files/file_opener.dart';
|
||||
export 'src/files/file_picker.dart';
|
||||
export 'src/files/file_saver.dart';
|
||||
export 'src/files/file_server_gateway.dart';
|
||||
|
|
|
|||
74
packages/halkyon_app/lib/src/files/file_opener.dart
Normal file
74
packages/halkyon_app/lib/src/files/file_opener.dart
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// Opens a file in the app the system picks for it (Halkyon's ADR 0043, D1):
|
||||
/// an attachment's PDF in the PDF viewer, its spreadsheet in an office app.
|
||||
/// A fake in tests.
|
||||
abstract interface class FileOpener() {
|
||||
/// Opens [bytes], a file named [name] of [mimeType]; whether an app took
|
||||
/// it (false: none could, or the platform opens none, iOS).
|
||||
Future<bool> open(
|
||||
Uint8List bytes, {
|
||||
required String name,
|
||||
required String mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
/// [FileOpener] as the platform opens files: on Android through halkyon_app's
|
||||
/// own plugin (`HalkyonAppPlugin.java`, a content URI from its FileProvider,
|
||||
/// readable by the app it goes to alone), on the desktop as a `file:` URI.
|
||||
/// Either way it opens a copy under its name, which other apps show, in
|
||||
/// the cache's `halkyon_opened/` folder (Android's provider shares that folder
|
||||
/// alone), and leaves the files the app keeps as they are; the copies go
|
||||
/// with the cache, and each time this opens one.
|
||||
class SystemFileOpener(final Directory _cache) implements FileOpener {
|
||||
static const _files = MethodChannel('app.halkyon/files');
|
||||
|
||||
@override
|
||||
Future<bool> open(
|
||||
Uint8List bytes, {
|
||||
required String name,
|
||||
required String mimeType,
|
||||
}) async {
|
||||
if (!(Platform.isAndroid ||
|
||||
Platform.isLinux ||
|
||||
Platform.isMacOS ||
|
||||
Platform.isWindows)) {
|
||||
return false;
|
||||
}
|
||||
final folder = Directory(p.join(_cache.path, 'halkyon_opened'));
|
||||
if (folder.existsSync()) {
|
||||
// Those opened before are done with: an app holding one open keeps
|
||||
// reading it where the system allows.
|
||||
await folder.delete(recursive: true);
|
||||
}
|
||||
// A folder by its content, so two files of one name don't meet.
|
||||
final file = File(
|
||||
p.join(
|
||||
folder.path,
|
||||
sha256.convert(bytes).toString().substring(0, 16),
|
||||
_safeName(name),
|
||||
),
|
||||
);
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
if (Platform.isAndroid) {
|
||||
return await _files.invokeMethod<bool>('open', {
|
||||
'path': file.path,
|
||||
'mimeType': mimeType,
|
||||
}) ??
|
||||
false;
|
||||
}
|
||||
return await launchUrl(Uri.file(file.path));
|
||||
}
|
||||
|
||||
/// [name] as one file name, nothing of a path in it.
|
||||
static String _safeName(String name) {
|
||||
final base = p.basename(name.replaceAll(r'\', '/')).trim();
|
||||
return base.isEmpty || base == '.' || base == '..' ? 'file' : base;
|
||||
}
|
||||
}
|
||||
22
packages/halkyon_app/lib/src/testing/fake_file_opener.dart
Normal file
22
packages/halkyon_app/lib/src/testing/fake_file_opener.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/src/files/file_opener.dart';
|
||||
|
||||
/// A [FileOpener] that notes what it was asked to open.
|
||||
class FakeFileOpener() implements FileOpener {
|
||||
/// What was opened, in order.
|
||||
final opened = <({String name, String mimeType, Uint8List bytes})>[];
|
||||
|
||||
/// Whether an app takes what is opened.
|
||||
bool takes = true;
|
||||
|
||||
@override
|
||||
Future<bool> open(
|
||||
Uint8List bytes, {
|
||||
required String name,
|
||||
required String mimeType,
|
||||
}) async {
|
||||
opened.add((name: name, mimeType: mimeType, bytes: bytes));
|
||||
return takes;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ export 'src/testing/fake_account_crypto.dart';
|
|||
export 'src/testing/fake_assistant_gateway.dart';
|
||||
export 'src/testing/fake_auth_gateway.dart';
|
||||
export 'src/testing/fake_content_cipher.dart';
|
||||
export 'src/testing/fake_file_opener.dart';
|
||||
export 'src/testing/fake_file_picker.dart';
|
||||
export 'src/testing/fake_file_server_gateway.dart';
|
||||
export 'src/testing/fake_instance_checker.dart';
|
||||
|
|
|
|||
Loading…
Reference in a new issue