Receive shared Android vault imports

This commit is contained in:
Joe Julian
2026-04-06 16:54:14 -07:00
parent 05345559ee
commit 4f5465dfff
5 changed files with 257 additions and 12 deletions
+18
View File
@@ -22,3 +22,21 @@
android:name="android.accessibilityservice" android:name="android.accessibilityservice"
android:resource="@xml/keepassgo_accessibility_service" /> android:resource="@xml/keepassgo_accessibility_service" />
</service> </service>
<activity
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/x-keepass2" />
<data android:mimeType="application/vnd.keepass" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
</intent-filter>
</activity>
Binary file not shown.
@@ -0,0 +1,122 @@
package org.julianfamily.keepassgo;
import android.app.Activity;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
public final class SharedVaultImportActivity extends Activity {
private static final String TAG = "KeePassGOImport";
private static final String DEFAULT_NAME = "shared-vault.kdbx";
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
handleIntent(getIntent());
launchMainActivity();
finish();
}
private void handleIntent(Intent intent) {
Uri uri = resolveSharedUri(intent);
if (uri == null) {
Log.i(TAG, "no shared vault URI on intent");
return;
}
try {
persistPendingImport(uri);
Log.i(TAG, "queued shared vault import from " + uri);
} catch (IOException err) {
Log.e(TAG, "failed to queue shared vault import", err);
}
}
private Uri resolveSharedUri(Intent intent) {
if (intent == null) {
return null;
}
String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action)) {
return intent.getParcelableExtra(Intent.EXTRA_STREAM);
}
if (Intent.ACTION_VIEW.equals(action)) {
return intent.getData();
}
return null;
}
private void persistPendingImport(Uri uri) throws IOException {
File dir = new File(getFilesDir(), "keepassgo");
if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("failed to create " + dir.getAbsolutePath());
}
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
try (InputStream in = getContentResolver().openInputStream(uri)) {
if (in == null) {
throw new IOException("failed to open shared vault stream");
}
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
byte[] buffer = new byte[8192];
int count;
while ((count = in.read(buffer)) >= 0) {
out.write(buffer, 0, count);
}
}
}
File nameFile = new File(dir, "pending-shared-vault-name.txt");
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
}
}
private String resolveDisplayName(Uri uri) {
String displayName = queryDisplayName(uri);
if (!displayName.isEmpty()) {
return displayName;
}
String lastSegment = uri.getLastPathSegment();
if (lastSegment != null && !lastSegment.trim().isEmpty()) {
return lastSegment.trim();
}
return DEFAULT_NAME;
}
private String queryDisplayName(Uri uri) {
Cursor cursor = null;
try {
cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (index >= 0) {
String value = cursor.getString(index);
if (value != null) {
return value.trim();
}
}
}
} catch (RuntimeException err) {
Log.w(TAG, "failed to query display name", err);
} finally {
if (cursor != null) {
cursor.close();
}
}
return "";
}
private void launchMainActivity() {
Intent launch = new Intent();
launch.setClassName(this, "org.gioui.GioActivity");
startActivity(launch);
}
}
+39
View File
@@ -143,6 +143,8 @@ type statePaths struct {
SettingsPath string SettingsPath string
UIPreferencesPath string UIPreferencesPath string
AutofillCachePath string AutofillCachePath string
PendingSharedVaultPath string
PendingSharedVaultNamePath string
} }
type recentVaultRecord struct { type recentVaultRecord struct {
@@ -452,6 +454,8 @@ type ui struct {
uiPreferencesPath string uiPreferencesPath string
recentRemotesPath string recentRemotesPath string
autofillCachePath string autofillCachePath string
pendingSharedVaultPath string
pendingSharedVaultNamePath string
editingEntry bool editingEntry bool
syncDefaultSourceMode syncSourceMode syncDefaultSourceMode syncSourceMode
syncDefaultDirection syncDirection syncDefaultDirection syncDirection
@@ -615,6 +619,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
uiPreferencesPath: paths.UIPreferencesPath, uiPreferencesPath: paths.UIPreferencesPath,
recentRemotesPath: paths.RecentRemotesPath, recentRemotesPath: paths.RecentRemotesPath,
autofillCachePath: paths.AutofillCachePath, autofillCachePath: paths.AutofillCachePath,
pendingSharedVaultPath: paths.PendingSharedVaultPath,
pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath,
recentVaultGroups: map[string][]string{}, recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{}, recentVaultUsedAt: map[string]time.Time{},
lifecycleAdvancedHidden: true, lifecycleAdvancedHidden: true,
@@ -655,6 +661,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.setCustomFieldRows(nil) u.setCustomFieldRows(nil)
u.loadRecentVaults() u.loadRecentVaults()
u.loadRecentRemotes() u.loadRecentRemotes()
u.consumePendingSharedVaultImport()
u.restoreStartupLifecycleTarget() u.restoreStartupLifecycleTarget()
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
u.loadUIPreferences() u.loadUIPreferences()
@@ -731,6 +738,8 @@ func defaultStatePaths(stateDir string) statePaths {
SettingsPath: filepath.Join(baseDir, "settings.json"), SettingsPath: filepath.Join(baseDir, "settings.json"),
UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"), UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"),
AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"), AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"),
PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"),
} }
} }
@@ -1494,6 +1503,36 @@ func (u *ui) importedVaultDestination(name string) string {
} }
} }
func (u *ui) consumePendingSharedVaultImport() {
path := strings.TrimSpace(u.pendingSharedVaultPath)
if path == "" {
return
}
content, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
}
return
}
name := "shared-vault.kdbx"
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
if rawName, err := os.ReadFile(namePath); err == nil {
if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" {
name = trimmed
}
}
}
if err := u.importSharedVaultBytesAction(name, content); err != nil {
u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err)
return
}
_ = os.Remove(path)
if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" {
_ = os.Remove(namePath)
}
}
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error { func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
target := u.importedVaultDestination(name) target := u.importedVaultDestination(name)
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
+66
View File
@@ -5651,6 +5651,12 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
if got := paths.AutofillCachePath; got != filepath.Join(base, "autofill-cache.json") { if got := paths.AutofillCachePath; got != filepath.Join(base, "autofill-cache.json") {
t.Fatalf("AutofillCachePath = %q, want %q", got, filepath.Join(base, "autofill-cache.json")) t.Fatalf("AutofillCachePath = %q, want %q", got, filepath.Join(base, "autofill-cache.json"))
} }
if got := paths.PendingSharedVaultPath; got != filepath.Join(base, "pending-shared-vault.kdbx") {
t.Fatalf("PendingSharedVaultPath = %q, want %q", got, filepath.Join(base, "pending-shared-vault.kdbx"))
}
if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") {
t.Fatalf("PendingSharedVaultNamePath = %q, want %q", got, filepath.Join(base, "pending-shared-vault-name.txt"))
}
} }
func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) { func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) {
@@ -5721,6 +5727,66 @@ func TestUIImportSharedVaultBytesActionCopiesVaultAndSelectsIt(t *testing.T) {
} }
} }
func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
t.Parallel()
dir := t.TempDir()
paths := statePaths{
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
PendingSharedVaultPath: filepath.Join(dir, "pending-shared-vault.kdbx"),
PendingSharedVaultNamePath: filepath.Join(dir, "pending-shared-vault-name.txt"),
}
key := vault.MasterKey{Password: "correct horse battery staple"}
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
},
}, key); err != nil {
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
if err := os.WriteFile(paths.PendingSharedVaultPath, encoded.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(PendingSharedVaultPath) error = %v", err)
}
if err := os.WriteFile(paths.PendingSharedVaultNamePath, []byte("crew-shared.kdbx\n"), 0o600); err != nil {
t.Fatalf("WriteFile(PendingSharedVaultNamePath) error = %v", err)
}
u := newUIWithState("phone", &session.Manager{}, paths)
wantPath := filepath.Join(dir, "crew-shared.kdbx")
if got := u.vaultPath.Text(); got != wantPath {
t.Fatalf("vaultPath = %q, want %q", got, wantPath)
}
if got := u.lifecycleMode; got != "local" {
t.Fatalf("lifecycleMode = %q, want local", got)
}
if !u.hasSelectedLifecycleTarget() {
t.Fatal("hasSelectedLifecycleTarget() = false, want true after pending import")
}
if _, err := os.Stat(paths.PendingSharedVaultPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Stat(PendingSharedVaultPath) error = %v, want not exist", err)
}
if _, err := os.Stat(paths.PendingSharedVaultNamePath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Stat(PendingSharedVaultNamePath) error = %v, want not exist", err)
}
reopened := newUIWithState("phone", &session.Manager{}, paths)
reopened.vaultPath.SetText(wantPath)
reopened.masterPassword.SetText(key.Password)
if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction(imported) error = %v", err)
}
reopened.state.NavigateToPath([]string{"Crew", "Internet"})
reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
}
}
func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) { func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) {
base := filepath.Join(t.TempDir(), "keepassgo-state-env") base := filepath.Join(t.TempDir(), "keepassgo-state-env")
t.Setenv("KEEPASSGO_STATE_DIR", base) t.Setenv("KEEPASSGO_STATE_DIR", base)