From d037393a1648a780ed6a1a7e0a85d682c11582a2 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 6 Apr 2026 17:02:30 -0700 Subject: [PATCH] Share local vaults from Android --- android/application_snippets.xml | 5 + android/keepassgo-android.jar | Bin 19055 -> 23214 bytes android_share_android.go | 208 ++++++++++++++++++ android_share_stub.go | 7 + .../julianfamily/keepassgo/AndroidShare.java | 81 +++++++ .../keepassgo/SharedVaultProvider.java | 100 +++++++++ main.go | 43 ++++ main_test.go | 85 ++++++- 8 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 android_share_android.go create mode 100644 android_share_stub.go create mode 100644 androidsrc/org/julianfamily/keepassgo/AndroidShare.java create mode 100644 androidsrc/org/julianfamily/keepassgo/SharedVaultProvider.java 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" /> + zM+wm`d2`?U%YEN-o@cGIziaJs&i-S4_Ss3oDzC#L(NV|2B?16|003!30tpxPl@$xP zwsnlxc8c%HBJr`rC`cm^CV+ojd`Ni+JzUQvD|8-Z z6Le^9(DIpA(m{619ic9$&+Fg{{sG?uU0H)4EqhpeN|*Y%u+q}s^6T5B3!KCP0QbQz zk}o5P!@d&$qG6wah@|Pq?yc=k=cTx@9%qh|F03c$%*~22WA4biz4nAkW`C;%SmSoC zn}Uw&*{6mIY1PHLTjrE;dvJ)n(U~2d#9yh{8(JOQd!XdTJaUtxR z$(63KjL|Oz3A!24&CvFYts;aKT;11+$2naMOPG=TE>uD2h(|mqoRwCzY;Vn)sqjrV zwHIu7P^nOuuVVJ75wayxolzm;A=k{NM-3(!j5Mn}aDkM8=;zlSb(o&nzp8wHR;KRbVERNE z_YKd4oTp?E26#+Cf&JqIHDtf1?FfTeB0Q8pp`=jx{Cv@F?MtP=wLO9GrCwv zNXv+8D=fs;+B>MZK6rwmjS*o@!Xq9xTtn!j?}F{5Qin3L{Svc1Kp0n#g zyxa3AFjstYOmfiGenF==&?HHx0cMr^R7DBd!unZf!*8+-mdP+&JvHNE88v>K8}epu-wWnd153*>mONZrz>5d4MTfUYy%+D*0+-wwIN5vN z@i5B2vkYAhFW#IkW!M>g;=`l0pi>$2(5opBSash(vOwa?q-QaIC>Zz z6l*u{$>!XN)p7fv3)O$|q?H+d*xX=q22&VZkp=17!h;tYJ)K1i%)3k#ICFNOnmx9Q zgJe&JLSNUNALHmx;)w=?^>iTmVvPiX;ht-k-s%f`|0(=E z?yez~4R7RYDp(5cYF?nO4*`Pa$4$*FEh z@A41}X@)F;FV;MZbyEw+c+cbaVBl=hzJ)T?G0z3Hd-Xdp{l2WLrtP0c zEapIxk6YvKOdH*sQY!CJ_{97&dZBM9qMwE*H+)BWil_IyUz`%D=)Q|mk2m%6SLJ7Z z*6*b7uRK+2Upt+th-8t1{WK5Cad%W+h$(3JfNufTQbugJI5(jpT z1;SN*+;nWiTl>VwbnO{q!HSTABH~+>+Q)nsbJ4!+;?<#daPRC_H4>Rb#!|jYwN=kn zo9fSgKHVDg4S)S@a}bV6HL*RY(M>AwHj-^Eba&_mIEL#!JuH|hXsAb;r&5KbPMx#! zZLDR$SB|92_=<>`5st4vN63F50_yw;2m9e-JLkrL(}{FoLSd~w5UmaP3G4My>0BY* zn=t;w6{6ki$M^LerBU8H!da^-5vf#6zlr~@$~--mx3Cf^kZdy1t(l0%A07bsim4vJ-6{_z--Zf_$0$)c-f~y?(zuD1Auq$2FFN-vW8NRql0sd zp&s@5-x7`bJ)73qRbK4nIrFVcxIl$z$2Aw;g)vcS4}6GV9eSI6+Voya0c)(iKd-< zv%WxWGPIz+m!%Pra}k4<>typ3+JyD=I+TkRN=QrOB!sQ#4tw(&D-(D}*cX#bb!!)L zFWiD?58R(_>S`};s*&FKVBf)yHk2ZLD>ISC;8&B~}$I4Jy1P+angy*ni zmW19?o99D={n_S=7+>PP(4&Xoa}6)3Nm61&_U_fWDm=>yIbY+D!WY}$e!J7=22nfH z>Chy?-(x|nY7QpZtP=XDL8WC60gC=-z7mzw^b~?8ks(a;8kI19pAUZVIUedyC(pBD zmjGK1VepU~I}yW%fv@iH;`{h)F#_|A#llvSW;qr!{KL95&gN&+?kV6-28y#7q3rY< z4GZ)O>wB33vxMJ#J{ZL zPc+0(DNvyDsFGm@r6|4lZecb|-;jqc>3Q8_of*50R2GT>Jsw>sS(4a(n=+J4Jl8xd zr-jNoG^c2_X+Nc`h3D~MZHRwoiH5_f?RMVkQ1r?v6{`Jw+|i@sC1*Z2SQ!^Rz||Xa z1h?U~&4QoxskGZq9pg7f{F@-Qw4j2rceT9MBGaY6x%L? zT+*DccOWbCmgIT$dCtgWWoX4$pd2S_a649?2Mxvjy#8TK6qiF!AAT%m3=6+kafZ4A z=x3ALsPM=poEPWnAs-2Dx_k78VCWz2rUH#pt-)O#lth`G&$zV~Bc=oK`1j85i`C17mvGpgmVlL6Ju@ zA7C}-c6i#C^2qLmAT6Qmm`C1lO}YgE5>sEA;g83>gWm-d$pqOm-4C9>H1Hnj zwJ+T<#ykCJ=$qL}dhr^>j!AXOA!+4$uL{hYU{<9W2S0EV| z-3J7H2*ZRNR;6KVVm^Y|MHOxy+AO=?5`&ed(uK+&?%(_ZcM{1}jy|rUY5IzZz%T?i zVtnp0i@)v|3(n{?Zs9zd`V44xqmuEB`{V@2&1C#Ar1FU*rf5Qe*RdXnCwobZ>FQRfGGO0xLt zr&4cWp!rzx^HD1+Qq1e=_J{L7)WXgN51x}&yy^=V`${$yFQ>Xl?2FcD4O9%QMVwjw zYK?5@HD$`m)?E@iITV~>XxS2SS(Fah%z6^@z!kHzULjC+8C-HgcUEP+drJ-Y$cwai zfScV)Uum8qo$Jfc6^w54fBB?Y$sdBH#dyCW+E#-Rp>}_aifs!w_~RujTd$E2%i|YkjW_3!PqgkOj3j_}WydeEugf`BN~kjFRXHCZy)Nfi*hGMTAN-Nq z_av^XGMCcbYv-sWaP2abWUk%3lFIMH|37Jf4=Jq7C-Z;K{@0^Y0seytQg}(q0BitY z83zE6_{YBk06>`#$;d-@g$~@;Q^Y9KVwVyBQ7DnK%JkRjuPfD(^ncYfyqicfmEZK> zq(X}=NcJZJLN=*fGX`?-N|j)`QaO;aw<(bU5DMP^UQp8ADRp5I0N@@30FeD_K{r_b zbcEdayA!ffl@`1F&Q(+d`I3v^idt?lUL#OLQE!Pp763qf^?u5K4W%RaCzrGXo z#RfyKRH^?S{PHR?B7YU3yW$xp?!Q0ZKQ~PHuPOF5{&XQ`{TqRF)ba4IGbyfa(W|G& I53XDP0=-fh`~Uy| delta 477 zcmZ3tmGS))MxFp~W)?061`Y;>&21BTG?{>;c@2o%ssJLd>jKHiY>eh0O4(Rp5qTYrWfGGJ;TM!i&>Mppstu4lyb&4AU69dCzW(Ee! z$=gD8CpU1)PL2)(TbCIo!!(O|vO<`_570MzHgfDHUt z!HU=NicPi-2OHrTF2f|p4iwd&JS`mLr^%~;B5IOA5!uNB#$1!%hl8D^7$L*-TM9{3 zcyeU~SbZ;0X0|$1Mq=`V2zihxjFB=-wwg$y?31G-CBV)rsk$2<%*Vj6ULNQrWY;mU sXoEFuh|~fZaR+F`K1;AjeH8a(Q6~|x0B=?{kPdzztYl?icmVbu036$p%K!iX diff --git a/android_share_android.go b/android_share_android.go new file mode 100644 index 0000000..4c7a7ce --- /dev/null +++ b/android_share_android.go @@ -0,0 +1,208 @@ +//go:build android + +package main + +/* +#cgo CFLAGS: -Werror +#cgo LDFLAGS: -landroid + +#include +#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)