Compare commits

...

2 Commits

Author SHA1 Message Date
079b650d60 fix(android-storage): add writable directory fallback for wallet JSON files
- Resolve Android storage dir at runtime (Documents/Data) and reuse it for saves
- List/read wallets across candidate dirs with dedupe by latest mtime
- Validate wallet filenames to prevent invalid paths
- Update UI hints for Desktop vs Android storage locations
2026-03-10 15:35:46 +01:00
7b2ff5445b fix(android): make Docker APK build pass with Capacitor config, synced lockfile, and JDK 21
- Track frontend/capacitor.config.json with appId/appName/webDir for Capacitor Android setup
- Unignore frontend/capacitor.config.json so config is committed with the project
- Update Android Docker builder to use JDK 21 (required by current Capacitor/Gradle toolchain)
- Switch Docker CMD to JSON-array form for safer signal handling
- Refresh frontend package-lock.json to match Capacitor and crypto dependencies required by npm ci
2026-03-10 14:37:58 +01:00
7 changed files with 1127 additions and 33 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ __pycache__/
!package-lock.json !package-lock.json
!frontend/package.json !frontend/package.json
!frontend/package-lock.json !frontend/package-lock.json
!frontend/capacitor.config.json
# Frontend build output and dependencies # Frontend build output and dependencies
node_modules/ node_modules/

View File

@@ -1,15 +1,20 @@
FROM node:22.14.0-bookworm FROM node:22.14.0-bookworm
# ── System deps: JDK 17 + tooling ───────────────────────────────────────────── # ── System deps + JDK 21 (required by current Capacitor Android toolchain) ───
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
default-jdk \ ca-certificates \
wget unzip \ wget unzip \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/opt/java/openjdk
RUN mkdir -p "${JAVA_HOME}" && \
wget -qO- "https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jdk/hotspot/normal/eclipse?project=jdk" \
| tar -xz -C "${JAVA_HOME}" --strip-components=1
# ── Android SDK command-line tools ──────────────────────────────────────────── # ── Android SDK command-line tools ────────────────────────────────────────────
# https://developer.android.com/studio#command-line-tools-only # https://developer.android.com/studio#command-line-tools-only
ENV ANDROID_SDK_ROOT=/opt/android-sdk ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV PATH="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}" ENV PATH="${JAVA_HOME}/bin:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
RUN mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" && \ RUN mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" && \
wget -q \ wget -q \
@@ -51,5 +56,4 @@ RUN cd frontend/android && \
./gradlew assembleDebug --no-daemon ./gradlew assembleDebug --no-daemon
# ── Export APK ──────────────────────────────────────────────────────────────── # ── Export APK ────────────────────────────────────────────────────────────────
CMD cp frontend/android/app/build/outputs/apk/debug/app-debug.apk \ CMD ["cp", "frontend/android/app/build/outputs/apk/debug/app-debug.apk", "/out/wallet-gen-debug.apk"]
/out/wallet-gen-debug.apk

View File

@@ -0,0 +1,6 @@
{
"appId": "com.walletgen.app",
"appName": "wallet-gen",
"webDir": "dist",
"bundledWebRuntime": false
}

File diff suppressed because it is too large Load Diff

View File

@@ -230,7 +230,7 @@ export default function HDWallet() {
value={saveFilename} value={saveFilename}
onChange={e => setSaveFilename(e.target.value)} onChange={e => setSaveFilename(e.target.value)}
/> />
<span className="form-hint">.json · saved to ~/.wallet-generator/hdwallet/</span> <span className="form-hint">.json · Desktop: ~/.wallet-generator/hdwallet/ · Android: app storage</span>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label">Encryption password (optional)</label> <label className="form-label">Encryption password (optional)</label>

View File

@@ -156,7 +156,7 @@ export default function SingleAddress({ initialTab = 'p2pkh' }) {
value={filename} value={filename}
onChange={e => setFilename(e.target.value)} onChange={e => setFilename(e.target.value)}
/> />
<span className="form-hint">.json · saved to ~/.wallet-generator/single/</span> <span className="form-hint">.json · Desktop: ~/.wallet-generator/single/ · Android: app storage</span>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label">Encryption password (optional)</label> <label className="form-label">Encryption password (optional)</label>

View File

@@ -22,6 +22,7 @@ const WALLET_DIRS = {
} }
let _fs = null let _fs = null
let _storageDirectory = null
async function fs() { async function fs() {
if (!_fs) { if (!_fs) {
const { Filesystem, Directory } = await import('@capacitor/filesystem') const { Filesystem, Directory } = await import('@capacitor/filesystem')
@@ -30,49 +31,141 @@ async function fs() {
return _fs return _fs
} }
function walletDir(kind) {
const dir = WALLET_DIRS[kind]
if (!dir) throw new Error(`Unsupported wallet kind: '${kind}'.`)
return dir
}
function normalizeFilename(filename) {
if (typeof filename !== 'string') throw new Error('Filename must be a string.')
const trimmed = filename.trim()
if (!trimmed) throw new Error('Filename is required.')
if (trimmed.includes('/') || trimmed.includes('\\') || trimmed === '.' || trimmed === '..') {
throw new Error('Invalid filename.')
}
return trimmed
}
function unique(arr) {
return [...new Set(arr.filter(Boolean))]
}
function toMtime(value) {
if (!value) return 0
if (typeof value === 'number') return value
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? 0 : parsed
}
function isMissingFileError(err) {
const msg = String(err?.message || err || '').toLowerCase()
return (
msg.includes('not found') ||
msg.includes('does not exist') ||
msg.includes('no such file') ||
msg.includes('directory at')
)
}
function directoryLabel(directory, Directory) {
if (directory === Directory.Documents) return 'Documents'
if (directory === Directory.Data) return 'Data'
return 'Storage'
}
async function directoryCandidates() {
const { Directory } = await fs()
return unique([Directory.Documents, Directory.Data])
}
async function resolveWritableDirectory() {
if (_storageDirectory) return _storageDirectory
const { Filesystem } = await fs()
let lastErr = null
for (const directory of await directoryCandidates()) {
try {
await Filesystem.mkdir({ path: 'wallet-generator', directory, recursive: true })
_storageDirectory = directory
return directory
} catch (err) {
lastErr = err
}
}
throw lastErr || new Error('No writable storage directory available.')
}
function orderedDirectories(preferred, all) {
if (!preferred) return all
return [preferred, ...all.filter(d => d !== preferred)]
}
async function androidSaveWallet(filename, data, kind) { async function androidSaveWallet(filename, data, kind) {
const { Filesystem, Directory } = await fs() const { Filesystem, Directory } = await fs()
const path = `${WALLET_DIRS[kind]}/${filename}` const safeName = normalizeFilename(filename)
await Filesystem.mkdir({ path: WALLET_DIRS[kind], directory: Directory.Documents, recursive: true }) const dirPath = walletDir(kind)
const path = `${dirPath}/${safeName}`
const directory = await resolveWritableDirectory()
await Filesystem.mkdir({ path: dirPath, directory, recursive: true })
await Filesystem.writeFile({ await Filesystem.writeFile({
path, path,
data: JSON.stringify(data, null, 2), data: JSON.stringify(data, null, 2),
directory: Directory.Documents, directory,
encoding: 'utf8', encoding: 'utf8',
}) })
return path return `${directoryLabel(directory, Directory)}/${path}`
} }
async function androidListWallets() { async function androidListWallets() {
const { Filesystem, Directory } = await fs() const { Filesystem } = await fs()
const out = { hd: [], single: [] } const out = { hd: [], single: [] }
const preferred = await resolveWritableDirectory().catch(() => null)
const dirs = orderedDirectories(preferred, await directoryCandidates())
for (const [kind, dir] of Object.entries(WALLET_DIRS)) { for (const [kind, dir] of Object.entries(WALLET_DIRS)) {
const byName = new Map()
try { try {
await Filesystem.mkdir({ path: dir, directory: Directory.Documents, recursive: true }) for (const directory of dirs) {
const { files } = await Filesystem.readdir({ path: dir, directory: Directory.Documents }) try {
out[kind] = files await Filesystem.mkdir({ path: dir, directory, recursive: true })
.filter(f => (f.type === 'file' || !f.type) && f.name.toLowerCase().endsWith('.json')) const { files = [] } = await Filesystem.readdir({ path: dir, directory })
.map(f => ({ files
.filter(f => (f.type === 'file' || !f.type) && f.name?.toLowerCase?.().endsWith('.json'))
.forEach(f => {
const item = {
name: f.name, name: f.name,
size: f.size || 0, size: f.size || 0,
mtimeMs: f.mtime ? (typeof f.mtime === 'number' ? f.mtime : Date.parse(f.mtime)) : 0, mtimeMs: toMtime(f.mtime),
})) }
.sort((a, b) => b.mtimeMs - a.mtimeMs) const prev = byName.get(item.name)
if (!prev || item.mtimeMs > prev.mtimeMs) byName.set(item.name, item)
})
} catch { } catch {
out[kind] = [] // Try next storage directory.
}
}
} finally {
out[kind] = Array.from(byName.values()).sort((a, b) => b.mtimeMs - a.mtimeMs)
} }
} }
return out return out
} }
async function androidReadWallet(kind, filename) { async function androidReadWallet(kind, filename) {
const { Filesystem, Directory } = await fs() const { Filesystem } = await fs()
const { data } = await Filesystem.readFile({ const path = `${walletDir(kind)}/${normalizeFilename(filename)}`
path: `${WALLET_DIRS[kind]}/${filename}`, const preferred = await resolveWritableDirectory().catch(() => null)
directory: Directory.Documents, const dirs = orderedDirectories(preferred, await directoryCandidates())
encoding: 'utf8', let lastErr = null
}) for (const directory of dirs) {
try {
const { data } = await Filesystem.readFile({ path, directory, encoding: 'utf8' })
return JSON.parse(data) return JSON.parse(data)
} catch (err) {
lastErr = err
if (!isMissingFileError(err)) throw err
}
}
throw lastErr || new Error('Wallet file not found.')
} }
// ── Exported API ───────────────────────────────────────────────────────────── // ── Exported API ─────────────────────────────────────────────────────────────
@@ -145,6 +238,8 @@ export const api = {
getWalletDir: async () => { getWalletDir: async () => {
if (isElectron) return window.electronAPI.getWalletDir() if (isElectron) return window.electronAPI.getWalletDir()
return 'Documents/wallet-generator' const { Directory } = await fs()
const directory = await resolveWritableDirectory()
return `${directoryLabel(directory, Directory)}/wallet-generator`
}, },
} }