Receive shared Android vault imports
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user