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)