fox_utils 0.1.7: clipboard, drag & drop, Android file saving

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FoxyzandClaude Opus 5.5 committed 2026-10-09 06:31:15 +03:00
commit dc16bba054
46 files changed
+4738

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx
+46
View File
@@ -0,0 +1,46 @@
group = "com.foxyz.fox_utils"
version = "0.1.4"
buildscript {
val kotlinVersion = "2.4.0"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:9.1.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
plugins {
id("com.android.library")
}
android {
namespace = "com.foxyz.fox_utils"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
minSdk = 24
}
}
dependencies {
// FileProvider for clipboard image sharing (pasteboard channel).
implementation("androidx.core:core:1.16.0")
}
+1
View File
@@ -0,0 +1 @@
rootProject.name = "fox_utils"
+17
View File
@@ -0,0 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.foxyz.fox_utils">
<application>
<!-- Serves clipboard images to other apps (pasteboard clipboard engine).
Authority matches what the plugin computes at runtime:
context.getPackageName() + ".provider". -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,306 @@
package com.foxyz.fox_utils;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Environment;
import androidx.annotation.NonNull;
import androidx.core.content.FileProvider;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
/**
* Fox utils Android plugin.
*
* <p>Two channels:
* <ul>
* <li>{@code fox_utils/android_files} — permission-free saving to public
* directories (see AndroidFiles.save).</li>
* <li>{@code pasteboard} — clipboard image/files read/write, ported from
* one.mixin.pasteboard (Apache-2.0) with fixes: byte-faithful image
* writes (no Bitmap re-encode), correct async executor use, and a
* working writeFiles clip assembly.</li>
* </ul>
*/
public class FoxUtilsPlugin implements FlutterPlugin, MethodCallHandler {
private static final String FILES_CHANNEL = "fox_utils/android_files";
private static final String CLIPBOARD_CHANNEL = "pasteboard";
private static final String CLIPBOARD_X_CHANNEL = "fox_utils/clipboard";
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private Context context;
@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) {
context = binding.getApplicationContext();
new MethodChannel(binding.getBinaryMessenger(), FILES_CHANNEL).setMethodCallHandler(this);
new MethodChannel(binding.getBinaryMessenger(), CLIPBOARD_CHANNEL).setMethodCallHandler(this);
new MethodChannel(binding.getBinaryMessenger(), CLIPBOARD_X_CHANNEL).setMethodCallHandler(this);
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
switch (call.method) {
// --- fox_utils/android_files ---
case "saveFile":
saveFile(call, result);
break;
// --- pasteboard (vendored) ---
case "image":
readImage(result);
break;
case "files":
readFiles(result);
break;
case "html":
readHtml(result);
break;
case "writeFiles":
writeFiles(call, result);
break;
case "writeImage":
writeImage(call, result);
break;
// --- fox_utils/clipboard ---
case "readFileBytes":
readFileBytes(call, result);
break;
default:
result.notImplemented();
}
}
// --- fox_utils/android_files ---
private void saveFile(MethodCall call, Result result) {
final byte[] bytes = call.argument("bytes");
final String fileName = call.argument("fileName");
final String location = call.argument("location");
final String subPath = call.argument("subPath");
final Boolean scan = call.argument("scan");
if (bytes == null || fileName == null || location == null || subPath == null || scan == null) {
result.error("INVALID_ARGUMENTS", "saveFile requires bytes, fileName, location, subPath and scan.", null);
return;
}
executor.execute(() -> {
try {
result.success(saveFileSync(bytes, fileName, location, subPath, scan));
} catch (Exception e) {
result.error("SAVE_FAILED", e.getMessage(), null);
}
});
}
@SuppressWarnings("deprecation") // Still works permission-free on modern Android.
private String saveFileSync(byte[] bytes, String fileName, String location, String subPath, boolean scan)
throws IOException {
File base = new File(Environment.getExternalStoragePublicDirectory(directoryFor(location)), subPath);
if (!base.exists() && !base.mkdirs()) {
throw new IOException("Could not create directory " + base);
}
File file = new File(base, fileName);
try (FileOutputStream out = new FileOutputStream(file)) {
out.write(bytes);
}
if (scan) {
MediaScannerConnection.scanFile(context, new String[]{file.getAbsolutePath()}, null, null);
}
return file.getAbsolutePath();
}
private static String directoryFor(String location) {
switch (location) {
case "movies":
return Environment.DIRECTORY_MOVIES;
case "music":
return Environment.DIRECTORY_MUSIC;
case "downloads":
return Environment.DIRECTORY_DOWNLOADS;
case "pictures":
default:
return Environment.DIRECTORY_PICTURES;
}
}
// --- pasteboard channel (vendored from one.mixin.pasteboard, Apache-2.0) ---
private ClipboardManager clipboard() {
return (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
}
private void readImage(Result result) {
executor.execute(() -> {
try {
ClipData.Item first = firstClipItem();
if (first == null || first.getUri() == null) {
result.success(null);
return;
}
Uri uri = first.getUri();
String mime = context.getContentResolver().getType(uri);
if (mime == null || !mime.startsWith("image")) {
result.success(null);
return;
}
try (InputStream in = context.getContentResolver().openInputStream(uri)) {
if (in == null) {
result.success(null);
return;
}
result.success(readAll(in));
}
} catch (Exception e) {
result.error("Error", "Failed to read image", e.getMessage());
}
});
}
private void readFiles(Result result) {
ClipData clip = clipboard().getPrimaryClip();
if (clip == null || clip.getItemCount() == 0) {
result.success(null);
return;
}
List<String> files = new ArrayList<>();
for (int i = 0; i < clip.getItemCount(); i++) {
Uri uri = clip.getItemAt(i).getUri();
if (uri != null) {
files.add(uri.toString());
}
}
result.success(files);
}
private void readHtml(Result result) {
ClipData.Item first = firstClipItem();
result.success(first == null ? null : first.getHtmlText());
}
private void writeFiles(MethodCall call, Result result) {
@SuppressWarnings("unchecked")
List<String> args = call.arguments();
if (args == null) {
result.error("NoArgs", "Missing Arguments", null);
return;
}
ClipboardManager manager = clipboard();
ClipData clip = null;
for (String path : args) {
Uri uri = Uri.parse(path);
if (clip == null) {
clip = ClipData.newUri(context.getContentResolver(), "files", uri);
} else {
clip.addItem(new ClipData.Item(uri));
}
}
if (clip != null) {
manager.setPrimaryClip(clip);
}
result.success(null);
}
private void writeImage(MethodCall call, Result result) {
byte[] image = call.arguments();
if (image == null) {
result.error("NoArgs", "Missing Arguments", null);
return;
}
// Bytes are staged as-is (no re-encode); the extension is sniffed from
// the magic bytes so receivers get the correct MIME type — animated
// GIFs and WebP survive copy/paste this way.
final File file = new File(context.getCacheDir(),
UUID.randomUUID().toString() + extensionFor(image));
executor.execute(() -> {
try {
try (FileOutputStream out = new FileOutputStream(file)) {
out.write(image);
}
Uri uri = FileProvider.getUriForFile(context, context.getPackageName() + ".provider", file);
clipboard().setPrimaryClip(ClipData.newUri(context.getContentResolver(), "image", uri));
result.success(null);
} catch (Exception e) {
result.error("Error", "Failed to write image", e.getMessage());
}
});
}
private ClipData.Item firstClipItem() {
ClipData clip = clipboard().getPrimaryClip();
return clip == null || clip.getItemCount() == 0 ? null : clip.getItemAt(0);
}
/** Resolves a content URI (e.g. a file manager's clipboard entry) to bytes. */
private void readFileBytes(MethodCall call, Result result) {
String uri = call.arguments();
if (uri == null) {
result.error("NoArgs", "Missing Arguments", null);
return;
}
executor.execute(() -> {
try (InputStream in = context.getContentResolver().openInputStream(Uri.parse(uri))) {
if (in == null) {
result.success(null);
return;
}
result.success(readAll(in));
} catch (Exception e) {
result.error("Error", "Failed to read file", e.getMessage());
}
});
}
/** Sniffs common image magic bytes so the pasted file carries the right MIME type. */
private static String extensionFor(byte[] b) {
if (b.length >= 3 && (b[0] & 0xFF) == 0xFF && (b[1] & 0xFF) == 0xD8 && (b[2] & 0xFF) == 0xFF) {
return ".jpg";
}
if (b.length >= 8 && (b[0] & 0xFF) == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) {
return ".png";
}
if (b.length >= 6 && (b[0] & 0xFF) == 0x47 && b[1] == 0x49 && b[2] == 0x46 && b[3] == 0x38) {
return ".gif";
}
if (b.length >= 12 && b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F'
&& b[4] == 'W' && b[5] == 'E' && b[6] == 'B' && b[7] == 'P') {
return ".webp";
}
if (b.length >= 2 && b[0] == 'B' && b[1] == 'M') {
return ".bmp";
}
return ".png";
}
private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
return out.toByteArray();
}
@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
context = null;
executor.shutdown();
}
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Clipboard images are staged in the app cache dir. -->
<cache-path name="cache" path="." />
</paths>