diff --git a/android/application_snippets.xml b/android/application_snippets.xml index a938cb3..8889103 100644 --- a/android/application_snippets.xml +++ b/android/application_snippets.xml @@ -22,6 +22,11 @@ android:name="android.accessibilityservice" android:resource="@xml/keepassgo_accessibility_service" /> + +#include + +static jint jni_GetCreatedJavaVMs(JavaVM **vmBuf, jsize bufLen, jsize *nVMs) { + return JNI_GetCreatedJavaVMs(vmBuf, bufLen, nVMs); +} + +static jint jni_AttachCurrentThread(JavaVM *vm, JNIEnv **p_env, void *thr_args) { + return (*vm)->AttachCurrentThread(vm, p_env, thr_args); +} + +static jint jni_DetachCurrentThread(JavaVM *vm) { + return (*vm)->DetachCurrentThread(vm); +} + +static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) { + return (*env)->GetObjectClass(env, obj); +} + +static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { + return (*env)->GetMethodID(env, clazz, name, sig); +} + +static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { + return (*env)->GetStaticMethodID(env, clazz, name, sig); +} + +static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { + return (*env)->CallObjectMethodA(env, obj, method, args); +} + +static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID methodID, const jvalue *args) { + (*env)->CallStaticVoidMethodA(env, cls, methodID, args); +} + +static jvalue jni_ValueObject(jobject obj) { + jvalue value; + value.l = obj; + return value; +} + +static jthrowable jni_ExceptionOccurred(JNIEnv *env) { + return (*env)->ExceptionOccurred(env); +} + +static void jni_ExceptionClear(JNIEnv *env) { + (*env)->ExceptionClear(env); +} + +static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) { + return (*env)->NewString(env, unicodeChars, len); +} + +static int jni_IsNull(jobject obj) { + return obj == NULL; +} +*/ +import "C" + +import ( + "fmt" + "strings" + "unicode/utf16" + "unsafe" + + "gioui.org/app" +) + +type androidVaultSharer struct{} + +func newPlatformVaultSharer(goos string) vaultSharer { + return androidVaultSharer{} +} + +func (androidVaultSharer) ShareVault(path, title string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("vault path is required") + } + ctx := C.jobject(unsafe.Pointer(app.AppContext())) + if C.jni_IsNull(ctx) != 0 { + return fmt.Errorf("android app context is not available") + } + vm, err := androidJavaVM() + if err != nil { + return err + } + var env *C.JNIEnv + if res := C.jni_AttachCurrentThread(vm, &env, nil); res != C.JNI_OK { + return fmt.Errorf("attach JVM thread: %d", int(res)) + } + defer C.jni_DetachCurrentThread(vm) + + sharerClass, err := androidLoadClass(env, ctx, "org.julianfamily.keepassgo.AndroidShare") + if err != nil { + return err + } + methodName := cString("shareVault") + defer C.free(unsafe.Pointer(methodName)) + methodSig := cString("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V") + defer C.free(unsafe.Pointer(methodSig)) + method := C.jni_GetStaticMethodID(env, sharerClass, methodName, methodSig) + if method == nil { + if err := androidJNIError(env, "resolve shareVault method"); err != nil { + return err + } + return fmt.Errorf("resolve shareVault method") + } + + jPath := androidJavaString(env, path) + jTitle := androidJavaString(env, title) + args := [3]C.jvalue{} + args[0] = C.jni_ValueObject(ctx) + args[1] = C.jni_ValueObject(C.jobject(jPath)) + args[2] = C.jni_ValueObject(C.jobject(jTitle)) + C.jni_CallStaticVoidMethodA(env, sharerClass, method, &args[0]) + if err := androidJNIError(env, "share vault"); err != nil { + return err + } + return nil +} + +func androidJavaVM() (*C.JavaVM, error) { + var vms [1]*C.JavaVM + var count C.jsize + if res := C.jni_GetCreatedJavaVMs(&vms[0], 1, &count); res != C.JNI_OK { + return nil, fmt.Errorf("get created Java VMs: %d", int(res)) + } + if count == 0 || vms[0] == nil { + return nil, fmt.Errorf("no Java VM available") + } + return vms[0], nil +} + +func androidLoadClass(env *C.JNIEnv, ctx C.jobject, name string) (C.jclass, error) { + var zeroClass C.jclass + contextClass := C.jni_GetObjectClass(env, ctx) + getClassLoaderName := cString("getClassLoader") + defer C.free(unsafe.Pointer(getClassLoaderName)) + getClassLoaderSig := cString("()Ljava/lang/ClassLoader;") + defer C.free(unsafe.Pointer(getClassLoaderSig)) + getClassLoader := C.jni_GetMethodID(env, contextClass, getClassLoaderName, getClassLoaderSig) + if getClassLoader == nil { + if err := androidJNIError(env, "resolve getClassLoader"); err != nil { + return zeroClass, err + } + return zeroClass, fmt.Errorf("resolve getClassLoader") + } + classLoader := C.jni_CallObjectMethodA(env, ctx, getClassLoader, nil) + if err := androidJNIError(env, "load class loader"); err != nil { + return zeroClass, err + } + if C.jni_IsNull(classLoader) != 0 { + return zeroClass, fmt.Errorf("android class loader is nil") + } + + classLoaderClass := C.jni_GetObjectClass(env, classLoader) + loadClassName := cString("loadClass") + defer C.free(unsafe.Pointer(loadClassName)) + loadClassSig := cString("(Ljava/lang/String;)Ljava/lang/Class;") + defer C.free(unsafe.Pointer(loadClassSig)) + loadClass := C.jni_GetMethodID(env, classLoaderClass, loadClassName, loadClassSig) + if loadClass == nil { + if err := androidJNIError(env, "resolve loadClass"); err != nil { + return zeroClass, err + } + return zeroClass, fmt.Errorf("resolve loadClass") + } + + jClassName := androidJavaString(env, name) + args := [1]C.jvalue{} + args[0] = C.jni_ValueObject(C.jobject(jClassName)) + loaded := C.jni_CallObjectMethodA(env, classLoader, loadClass, &args[0]) + if err := androidJNIError(env, "load AndroidShare class"); err != nil { + return zeroClass, err + } + if C.jni_IsNull(loaded) != 0 { + return zeroClass, fmt.Errorf("load AndroidShare class returned nil") + } + return C.jclass(loaded), nil +} + +func androidJNIError(env *C.JNIEnv, action string) error { + if thr := C.jni_ExceptionOccurred(env); C.jni_IsNull(C.jobject(thr)) == 0 { + C.jni_ExceptionClear(env) + return fmt.Errorf("%s: Java exception", action) + } + return nil +} + +func androidJavaString(env *C.JNIEnv, s string) C.jstring { + chars := utf16.Encode([]rune(s)) + if len(chars) == 0 { + return C.jni_NewString(env, nil, 0) + } + return C.jni_NewString(env, (*C.jchar)(unsafe.Pointer(unsafe.SliceData(chars))), C.jsize(len(chars))) +} + +func cString(value string) *C.char { + return C.CString(value) +} diff --git a/android_share_stub.go b/android_share_stub.go new file mode 100644 index 0000000..80a101f --- /dev/null +++ b/android_share_stub.go @@ -0,0 +1,7 @@ +//go:build !android + +package main + +func newPlatformVaultSharer(goos string) vaultSharer { + return nil +} diff --git a/androidsrc/org/julianfamily/keepassgo/AndroidShare.java b/androidsrc/org/julianfamily/keepassgo/AndroidShare.java new file mode 100644 index 0000000..9f62da8 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/AndroidShare.java @@ -0,0 +1,81 @@ +package org.julianfamily.keepassgo; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; + +public final class AndroidShare { + private static final String DEFAULT_TITLE = "KeePassGO Vault"; + + private AndroidShare() { + } + + public static void shareVault(Context context, String path, String title) throws IOException { + File source = new File(path); + if (!source.isFile()) { + throw new IOException("vault file not found: " + path); + } + File shared = copyToSharedExport(context, source); + Uri uri = SharedVaultProvider.uriForFile(shared.getName()); + + Intent send = new Intent(Intent.ACTION_SEND); + send.setType("application/x-keepass2"); + send.putExtra(Intent.EXTRA_STREAM, uri); + send.putExtra(Intent.EXTRA_TITLE, sanitizeTitle(title, source.getName())); + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + + Intent chooser = Intent.createChooser(send, "Share vault"); + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + context.startActivity(chooser); + } + + static File sharedDirectory(Context context) { + return new File(new File(context.getFilesDir(), "keepassgo"), "shared"); + } + + private static File copyToSharedExport(Context context, File source) throws IOException { + File dir = sharedDirectory(context); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("failed to create " + dir.getAbsolutePath()); + } + File target = new File(dir, sanitizeFilename(source.getName())); + try (FileInputStream in = new FileInputStream(source); + FileOutputStream out = new FileOutputStream(target, false)) { + byte[] buffer = new byte[8192]; + int count; + while ((count = in.read(buffer)) >= 0) { + out.write(buffer, 0, count); + } + } + return target; + } + + private static String sanitizeFilename(String name) { + String trimmed = name == null ? "" : name.trim(); + if (trimmed.isEmpty()) { + return "shared-vault.kdbx"; + } + if (trimmed.endsWith(".kdbx")) { + return trimmed; + } + return trimmed + ".kdbx"; + } + + private static String sanitizeTitle(String title, String fallbackName) { + String trimmed = title == null ? "" : title.trim(); + if (!trimmed.isEmpty()) { + return trimmed; + } + String fallback = fallbackName == null ? "" : fallbackName.trim(); + if (!fallback.isEmpty()) { + return fallback; + } + return DEFAULT_TITLE; + } +} diff --git a/androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java b/androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java new file mode 100644 index 0000000..2793516 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java @@ -0,0 +1,100 @@ +package org.julianfamily.keepassgo; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; + +import java.io.File; +import java.io.FileNotFoundException; + +public final class SharedVaultProvider extends ContentProvider { + private static final String AUTHORITY = "org.julianfamily.keepassgo.share"; + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + File file = resolveSharedFile(uri); + String[] columns = projection; + if (columns == null || columns.length == 0) { + columns = new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}; + } + MatrixCursor cursor = new MatrixCursor(columns, 1); + Object[] row = new Object[columns.length]; + for (int i = 0; i < columns.length; i++) { + switch (columns[i]) { + case OpenableColumns.DISPLAY_NAME: + row[i] = file.getName(); + break; + case OpenableColumns.SIZE: + row[i] = file.length(); + break; + default: + row[i] = null; + break; + } + } + cursor.addRow(row); + return cursor; + } + + @Override + public String getType(Uri uri) { + return "application/x-keepass2"; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + throw new UnsupportedOperationException("insert is not supported"); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException("delete is not supported"); + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException("update is not supported"); + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + File file = resolveSharedFile(uri); + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + } + + static Uri uriForFile(String name) { + return new Uri.Builder() + .scheme("content") + .authority(AUTHORITY) + .appendPath(name) + .build(); + } + + private File resolveSharedFile(Uri uri) { + if (getContext() == null) { + throw new IllegalStateException("provider context is unavailable"); + } + String name = sanitizeFilename(uri.getLastPathSegment()); + return new File(AndroidShare.sharedDirectory(getContext()), name); + } + + private static String sanitizeFilename(String name) { + if (name == null) { + return "shared-vault.kdbx"; + } + String trimmed = name.trim(); + if (trimmed.isEmpty()) { + return "shared-vault.kdbx"; + } + return new File(trimmed).getName(); + } +} diff --git a/main.go b/main.go index 7b5a1e2..4191d7a 100644 --- a/main.go +++ b/main.go @@ -307,6 +307,7 @@ type ui struct { cancelEdit widget.Clickable pickVaultPath widget.Clickable importSharedVault widget.Clickable + shareCurrentVault widget.Clickable pickKeyFile widget.Clickable pickSyncLocalPath widget.Clickable clearVaultSelection widget.Clickable @@ -426,6 +427,7 @@ type ui struct { settingsIcon *widget.Icon menuIcon *widget.Icon clipboardWriter clipboard.Writer + vaultSharer vaultSharer loadingMessage string loadingActionLabel string lifecycleMode string @@ -497,6 +499,10 @@ type backgroundActionResult struct { id int } +type vaultSharer interface { + ShareVault(path, title string) error +} + var ( bgColor = color.NRGBA{R: 242, G: 239, B: 233, A: 255} panelColor = color.NRGBA{R: 250, G: 248, B: 244, A: 255} @@ -635,6 +641,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) syncDefaultDirection: syncDirectionPull, apiPolicyGroupScope: true, autofillNoticePreference: autofillNoticeAll, + vaultSharer: newPlatformVaultSharer(runtime.GOOS), backgroundResults: make(chan backgroundActionResult, 8), phoneGroupBrowserExpanded: true, } @@ -772,6 +779,10 @@ func supportsSharedVaultImport(goos string) bool { return strings.EqualFold(strings.TrimSpace(goos), "android") } +func supportsVaultShare(goos string) bool { + return strings.EqualFold(strings.TrimSpace(goos), "android") +} + func (u *ui) selectedAttachmentItems() []attachmentItem { item, ok := u.selectedEntry() if !ok || len(item.Attachments) == 0 { @@ -1551,6 +1562,24 @@ func (u *ui) importSharedVaultBytesAction(name string, content []byte) error { return nil } +func (u *ui) currentShareableVaultPath() string { + return strings.TrimSpace(u.vaultPath.Text()) +} + +func (u *ui) shareCurrentVaultAction() error { + if u.vaultSharer == nil { + return fmt.Errorf("vault sharing is not available on this platform") + } + path := u.currentShareableVaultPath() + if path == "" { + return errors.New(errVaultPathRequired) + } + if err := u.state.Save(); err != nil { + return err + } + return u.vaultSharer.ShareVault(path, friendlyRecentVaultLabel(path)) +} + func (u *ui) noteRecentVault(path string) { path = strings.TrimSpace(path) if path == "" { @@ -3653,6 +3682,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.saveCurrentRemoteBinding.Clicked(gtx) { u.runAction("save remote binding", u.saveCurrentRemoteBindingAction) } + for u.shareCurrentVault.Clicked(gtx) { + u.runAction("share vault", u.shareCurrentVaultAction) + } for u.clearVaultSelection.Clicked(gtx) { if u.lifecycleBusy() { continue @@ -4777,6 +4809,17 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions { return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !supportsVaultShare(runtime.GOOS) || u.vaultSharer == nil || strings.TrimSpace(u.currentShareableVaultPath()) == "" { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.shareCurrentVault, "Share Vault") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + ) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync") }), diff --git a/main_test.go b/main_test.go index 8d2919c..089e1db 100644 --- a/main_test.go +++ b/main_test.go @@ -99,6 +99,33 @@ func (s *remoteOpenCaptureSession) OpenRemote(client webdav.Client, path string, return nil } +type saveCaptureSession struct { + model vault.Model + saveCount int + saveErr error +} + +func (s *saveCaptureSession) Current() (vault.Model, error) { + return s.model, nil +} + +func (s *saveCaptureSession) Save() error { + s.saveCount++ + return s.saveErr +} + +type captureVaultSharer struct { + path string + title string + err error +} + +func (s *captureVaultSharer) ShareVault(path, title string) error { + s.path = path + s.title = title + return s.err +} + func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { t.Parallel() @@ -367,7 +394,12 @@ func TestUIChildGroupsComeFromVaultModel(t *testing.T) { func TestUIAPITokenLifecycleManagement(t *testing.T) { t.Parallel() - u := newUIWithSession("desktop", &session.Manager{}) + u := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) u.masterPassword.SetText("correct horse battery staple") if err := u.createVaultAction(); err != nil { t.Fatalf("createVaultAction() error = %v", err) @@ -5787,6 +5819,57 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) { } } +func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) { + t.Parallel() + + u := newUIWithSession("phone", &session.Manager{}) + u.vaultPath.SetText("/vaults/crew-shared.kdbx") + + if got := u.currentShareableVaultPath(); got != "/vaults/crew-shared.kdbx" { + t.Fatalf("currentShareableVaultPath() = %q, want %q", got, "/vaults/crew-shared.kdbx") + } +} + +func TestUIShareCurrentVaultActionSavesAndSharesCurrentVault(t *testing.T) { + t.Parallel() + + session := &saveCaptureSession{} + sharer := &captureVaultSharer{} + u := newUIWithSession("phone", session) + u.vaultSharer = sharer + u.vaultPath.SetText("/vaults/crew-shared.kdbx") + + if err := u.shareCurrentVaultAction(); err != nil { + t.Fatalf("shareCurrentVaultAction() error = %v", err) + } + if session.saveCount != 1 { + t.Fatalf("shareCurrentVaultAction() saveCount = %d, want 1", session.saveCount) + } + if got := sharer.path; got != "/vaults/crew-shared.kdbx" { + t.Fatalf("ShareVault path = %q, want %q", got, "/vaults/crew-shared.kdbx") + } + if got := sharer.title; got != "crew-shared.kdbx" { + t.Fatalf("ShareVault title = %q, want %q", got, "crew-shared.kdbx") + } +} + +func TestUIShareCurrentVaultActionRequiresVaultPath(t *testing.T) { + t.Parallel() + + u := newUIWithSession("phone", &saveCaptureSession{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"), + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + u.vaultSharer = &captureVaultSharer{} + + err := u.shareCurrentVaultAction() + if err == nil || err.Error() != errVaultPathRequired { + t.Fatalf("shareCurrentVaultAction() error = %v, want %q", err, errVaultPathRequired) + } +} + func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) { base := filepath.Join(t.TempDir(), "keepassgo-state-env") t.Setenv("KEEPASSGO_STATE_DIR", base)