Share local vaults from Android

This commit is contained in:
Joe Julian
2026-04-06 17:02:30 -07:00
parent 4f5465dfff
commit d037393a16
8 changed files with 528 additions and 1 deletions
+5
View File
@@ -22,6 +22,11 @@
android:name="android.accessibilityservice"
android:resource="@xml/keepassgo_accessibility_service" />
</service>
<provider
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
android:authorities="org.julianfamily.keepassgo.share"
android:exported="false"
android:grantUriPermissions="true" />
<activity
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
android:exported="true"
Binary file not shown.
+208
View File
@@ -0,0 +1,208 @@
//go:build android
package main
/*
#cgo CFLAGS: -Werror
#cgo LDFLAGS: -landroid
#include <jni.h>
#include <stdlib.h>
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)
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !android
package main
func newPlatformVaultSharer(goos string) vaultSharer {
return nil
}
@@ -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;
}
}
@@ -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();
}
}
+43
View File
@@ -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")
}),
+84 -1
View File
@@ -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)