Skip to content

Commit e99918a

Browse files
feat: added sharing for google drive
1 parent 526377c commit e99918a

4 files changed

Lines changed: 508 additions & 0 deletions

File tree

android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,23 @@ object ConfigStore {
294294
/** Prefix for encoded config strings so we can detect them in clipboard. */
295295
private const val HASH_PREFIX = "mhrv-rs://"
296296

297+
/** Distinct prefix for the "Drive setup" share — bundles credentials
298+
* + refresh token so a recipient can connect with no manual OAuth.
299+
* Different from [HASH_PREFIX] because the payload includes secrets,
300+
* the recipient flow needs to write extra files, and we don't want
301+
* to silently fall through to the regular config import path. */
302+
private const val DRIVE_SETUP_PREFIX = "mhrv-rs-setup://"
303+
304+
/** Filename inside the app's filesDir where imported credentials are
305+
* written. Must match what the regular Drive import flow uses, so
306+
* a setup-import is indistinguishable from a manual import +
307+
* authorize after the fact. */
308+
private const val DRIVE_CREDENTIALS_FILE = "drive-credentials.json"
309+
310+
/** Token cache filename — `<credentials>.token` — same shape the
311+
* Rust side writes when a fresh OAuth dance completes. */
312+
private const val DRIVE_TOKEN_FILE = "drive-credentials.json.token"
313+
297314
/** Encode config as a shareable base64 string with prefix.
298315
* Only includes non-default fields to keep the hash short. */
299316
fun encode(cfg: MhrvConfig): String {
@@ -385,6 +402,175 @@ object ConfigStore {
385402
}
386403
}
387404

405+
// -----------------------------------------------------------------
406+
// Drive setup share — bundle credentials + refresh token + folder
407+
// ID so a fresh device can be onboarded with one QR scan and zero
408+
// technical steps. Distinct from [encode]/[decode] because that
409+
// flow deliberately omits secrets; this one deliberately includes
410+
// them and warns the sharer accordingly.
411+
// -----------------------------------------------------------------
412+
413+
/**
414+
* Drive-setup payload as it travels in the QR. Versioned in case we
415+
* later rotate the bundle shape.
416+
*
417+
* - [credentials]: full content of credentials.json (the OAuth
418+
* desktop client config — client_id + client_secret).
419+
* - [refreshToken]: the cached OAuth refresh token. The recipient
420+
* uses it directly without any browser dance.
421+
* - [folderId] / [folderName] / [pollMs] / [flushMs] / [idleSecs] /
422+
* [googleIp] / [frontDomain]: the same Drive-mode knobs that
423+
* apply on the recipient.
424+
*/
425+
data class DriveSetup(
426+
val credentials: String,
427+
val refreshToken: String,
428+
val folderId: String,
429+
val folderName: String,
430+
val pollMs: Int,
431+
val flushMs: Int,
432+
val idleSecs: Int,
433+
val googleIp: String,
434+
val frontDomain: String,
435+
)
436+
437+
/** Read the on-disk credentials + token files and bundle them with
438+
* the user's Drive config knobs into a shareable string. Returns
439+
* null when there's nothing to share (no credentials imported, or
440+
* no token cached yet — the sharer has to complete OAuth first). */
441+
fun encodeDriveSetup(ctx: Context, cfg: MhrvConfig): String? {
442+
if (cfg.driveCredentialsPath.isBlank()) return null
443+
val credsFile = File(cfg.driveCredentialsPath)
444+
if (!credsFile.exists()) return null
445+
val tokenFile = File(credsFile.absolutePath + ".token")
446+
if (!tokenFile.exists()) return null
447+
448+
val credentials = runCatching { credsFile.readText() }.getOrNull() ?: return null
449+
val refreshToken = runCatching {
450+
JSONObject(tokenFile.readText()).optString("refresh_token", "")
451+
}.getOrNull().orEmpty()
452+
if (refreshToken.isBlank()) return null
453+
454+
val defaults = MhrvConfig()
455+
val obj = JSONObject().apply {
456+
put("v", 1)
457+
put("credentials", credentials)
458+
put("refresh_token", refreshToken)
459+
if (cfg.driveFolderId.isNotBlank()) put("folder_id", cfg.driveFolderId)
460+
if (cfg.driveFolderName != defaults.driveFolderName) put("folder_name", cfg.driveFolderName)
461+
if (cfg.drivePollMs != defaults.drivePollMs) put("poll_ms", cfg.drivePollMs)
462+
if (cfg.driveFlushMs != defaults.driveFlushMs) put("flush_ms", cfg.driveFlushMs)
463+
if (cfg.driveIdleTimeoutSecs != defaults.driveIdleTimeoutSecs) put("idle_secs", cfg.driveIdleTimeoutSecs)
464+
if (cfg.googleIp != defaults.googleIp) put("google_ip", cfg.googleIp)
465+
if (cfg.frontDomain != defaults.frontDomain) put("front_domain", cfg.frontDomain)
466+
}
467+
468+
val raw = obj.toString().toByteArray(Charsets.UTF_8)
469+
val compressed = java.io.ByteArrayOutputStream().also { bos ->
470+
java.util.zip.DeflaterOutputStream(bos).use { it.write(raw) }
471+
}.toByteArray()
472+
val b64 = android.util.Base64.encodeToString(
473+
compressed,
474+
android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE,
475+
)
476+
return "$DRIVE_SETUP_PREFIX$b64"
477+
}
478+
479+
/** Cheap check used to dispatch a scanned / pasted blob to the
480+
* Drive-setup import path instead of the regular config-import
481+
* path (the two formats look different but both base64; the prefix
482+
* is what disambiguates). */
483+
fun looksLikeDriveSetup(text: String): Boolean =
484+
text.trim().startsWith(DRIVE_SETUP_PREFIX)
485+
486+
/** Decode a [DRIVE_SETUP_PREFIX] payload. Returns null if the blob
487+
* doesn't parse, lacks required fields, or has an unsupported
488+
* version. Does NOT touch disk — call [applyDriveSetup] to actually
489+
* import. */
490+
fun decodeDriveSetup(encoded: String): DriveSetup? {
491+
val trimmed = encoded.trim()
492+
val payload = trimmed.removePrefix(DRIVE_SETUP_PREFIX).trim()
493+
if (payload.isEmpty()) return null
494+
val raw = runCatching {
495+
android.util.Base64.decode(
496+
payload,
497+
android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE,
498+
)
499+
}.getOrNull() ?: return null
500+
val text = inflateOrRaw(raw)
501+
return try {
502+
val obj = JSONObject(text)
503+
if (obj.optInt("v", 0) != 1) return null
504+
val credentials = obj.optString("credentials", "")
505+
val refreshToken = obj.optString("refresh_token", "")
506+
if (credentials.isBlank() || refreshToken.isBlank()) return null
507+
val defaults = MhrvConfig()
508+
DriveSetup(
509+
credentials = credentials,
510+
refreshToken = refreshToken,
511+
folderId = obj.optString("folder_id", ""),
512+
folderName = obj.optString("folder_name", defaults.driveFolderName),
513+
pollMs = obj.optInt("poll_ms", defaults.drivePollMs),
514+
flushMs = obj.optInt("flush_ms", defaults.driveFlushMs),
515+
idleSecs = obj.optInt("idle_secs", defaults.driveIdleTimeoutSecs),
516+
googleIp = obj.optString("google_ip", defaults.googleIp),
517+
frontDomain = obj.optString("front_domain", defaults.frontDomain),
518+
)
519+
} catch (_: Throwable) {
520+
null
521+
}
522+
}
523+
524+
/**
525+
* Write the credentials + token files into the app's filesDir and
526+
* return an [MhrvConfig] reflecting the imported setup. The caller
527+
* is responsible for [save]'ing it (we keep this side-effect-free
528+
* apart from disk writes so callers can compose it into their own
529+
* "import + persist + snackbar" flow).
530+
*
531+
* On success returns the new config. On any I/O failure returns
532+
* null and tries to clean up partial writes — better to leave the
533+
* recipient in the original (empty) state than half-imported.
534+
*/
535+
fun applyDriveSetup(ctx: Context, base: MhrvConfig, setup: DriveSetup): MhrvConfig? {
536+
val credsFile = File(ctx.filesDir, DRIVE_CREDENTIALS_FILE)
537+
val tokenFile = File(ctx.filesDir, DRIVE_TOKEN_FILE)
538+
return try {
539+
credsFile.writeText(setup.credentials)
540+
tokenFile.writeText(JSONObject().apply {
541+
put("refresh_token", setup.refreshToken)
542+
}.toString())
543+
// Best-effort 0600. Android's FileProvider sandbox already
544+
// walls /data/user/0/<pkg>/files/ off from other apps, so
545+
// this is belt-and-braces.
546+
runCatching {
547+
credsFile.setReadable(false, false)
548+
credsFile.setReadable(true, true)
549+
credsFile.setWritable(false, false)
550+
credsFile.setWritable(true, true)
551+
tokenFile.setReadable(false, false)
552+
tokenFile.setReadable(true, true)
553+
tokenFile.setWritable(false, false)
554+
tokenFile.setWritable(true, true)
555+
}
556+
base.copy(
557+
mode = Mode.GOOGLE_DRIVE,
558+
driveCredentialsPath = credsFile.absolutePath,
559+
driveFolderId = setup.folderId,
560+
driveFolderName = setup.folderName,
561+
drivePollMs = setup.pollMs,
562+
driveFlushMs = setup.flushMs,
563+
driveIdleTimeoutSecs = setup.idleSecs,
564+
googleIp = setup.googleIp,
565+
frontDomain = setup.frontDomain,
566+
)
567+
} catch (_: Throwable) {
568+
runCatching { credsFile.delete() }
569+
runCatching { tokenFile.delete() }
570+
null
571+
}
572+
}
573+
388574
/** Check if a string looks like an encoded mhrv config. */
389575
fun looksLikeConfig(text: String): Boolean {
390576
val t = text.trim()

0 commit comments

Comments
 (0)