@@ -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