DEV Community

Roronoa
Roronoa

Posted on

Keep Model Shards Out of Shared Storage on Your First Mobile AI PR

You clone the team's React Native repository onto a Pixel 8 running Android 15 with a half-charged battery. The README tells you to launch the sample, grant storage permission, and wait while an on-device model downloads. You background the app during that transfer, then reopen Files after a process death, and an incomplete shard sits in Downloads. That first-hour leak is the problem this article walks you through, before your first PR and before your first rollback.

This write-up is a proposed onboarding experiment, not a farm-wide benchmark and not a source of invented timings. You should record the device, OS, framework version, network, and permission state before you treat any observation as evidence. The goal is that model bytes stay in app-private storage across backgrounding, process death, and a later config rollback.

Why shared storage shows up in your first hour

Junior engineers often copy a DownloadManager snippet that targets the public Downloads collection because it appears to work. Android then indexes those files through MediaStore, and Files or a USB cable can list every incomplete shard. iOS has a quieter version of the same mistake when you write into a shared container or skip the backup exclusion flag.

On-device inference makes the blast radius worse than a misplaced JSON configuration sitting in a cache directory. Model shards are large, sometimes user-specific after packaging, and they survive the activity that downloaded them. A first rollback of the model URL does not delete public files the operating system already indexed. Your first hour on the repo is exactly when those leftovers show up in a demo that still looks fine.

Watch for these first-hour smells in the sample you just cloned:

  • The sample requests READ_MEDIA_IMAGES or another broad storage grant only to fetch a model.
  • DownloadManager.Request calls setDestinationInExternalPublicDir toward public Downloads.
  • React Native react-native-fs writes into DownloadDirectoryPath or ExternalStorageDirectoryPath.
  • iOS code copies a downloaded weight into a document the user can browse inside Files.
  • A retry queue keeps .gguf.part or .tflite.tmp sitting beside the finished file.

Coding assistants are everywhere in onboarding chats this year, which makes the leak easier to spread. A teammate pastes the downloader, the assistant repeats the public-path snippet, and your first PR ships the same Files.app surprise. Treat the assistant as a reviewer of a private destination helper, not as a source of storage permissions.

Proposed first-hour experiment

Label this as a procedure you run on one device, then compare notes with a teammate who owns a different OS build. Do not treat the steps as proof that every OEM Files app hides app-specific external directories the same way. Fill the environment block with the hardware on your desk before you file a bug.

Example environment to record

  • Device: Pixel 8 or iPhone 15, with the actual board name if you have it.
  • OS: Android 15 or iOS 18, plus the security patch string on Android.
  • App: React Native 0.76, Flutter 3.24, or whatever your lockfile pins today.
  • Network: Wi-Fi first, then a mid-transfer switch onto cellular.
  • Power: battery saver off, then on, without inventing drain percentages.
  • Permissions: storage or photos granted at launch, then revoked from Settings.
  • Application state: foreground download, Home, process death, then a cold relaunch.

Exact steps

  1. Install a debug build from a clean profile so leftover models are not already present on disk.
  2. Start the model download, then press Home while the progress affordance is still active.
  3. Force-stop the app from Settings so you simulate process death in the middle of the transfer.
  4. Open the system Files app and search for the model name, .part, .tmp, .gguf, and .tflite.
  5. Connect adb or Finder and list public Downloads beside the app-specific models directory.
  6. Restore the previous model URL as your first config rollback, then relaunch the app.
  7. Search Files again and write down whether the incomplete shard recovered, restarted, or silently remained.

Expected observations, not measured claims

Public Downloads should contain zero model shards after a correct implementation lands in your first PR. App-private storage may still contain a truncated file, and your loader must refuse that file until a checksum matches. Rollback should not resurrect a public MediaStore row for the old URL after the force-stop. A permission revoke should stop a new public write rather than strand a world-readable temp file beside the old shard.

Commands you can run during that hour

# Android: public Downloads versus app-private models
adb shell ls -la /sdcard/Download | grep -E 'gguf|tflite|onnx|part|tmp'
adb shell run-as com.example.app ls -la files/models

# Android: MediaStore hits for model-like names
adb shell content query --uri content://media/external/file \
  --projection _display_name:_data:size \
  --where "_display_name LIKE '%.gguf%' OR _display_name LIKE '%.tflite%'"

# iOS Simulator: Application Support versus user-visible Documents
xcrun simctl get_app_container booted com.example.app data
# then inspect Library/Application Support/models and Documents
Enter fullscreen mode Exit fullscreen mode

If a shard appears in the public tree, stop shipping the downloader and treat the path as a first-PR defect. Capture the listing in the ticket, but strip other apps' filenames before you paste anything into chat.

First PR: move the handoff into app-private storage

Your first PR should not add a new model flavor or a new prompt template. It should change the destination, the integrity check, and the rollback cleanup so reviewers can reason about filesystem side effects. Keep the diff small, and put the experiment steps into the pull-request template so the next junior repeats them.

Android destination

Avoid public DownloadManager destinations even when the sample looks shorter that way. Prefer internal storage or getExternalFilesDir, which is removed on uninstall and stays out of the default Files view on stock Android. If you still need DownloadManager for retries, point it at that private tree and hide the notification so the filename is not a user-visible attachment.

// Proposed helper — unexecuted example, adapt to your module.
fun modelDir(context: Context): File {
    val dir = File(context.filesDir, "models")
    if (!dir.exists()) dir.mkdirs()
    return dir
}

fun enqueuePrivateDownload(context: Context, url: String, fileName: String): Long {
    val request = DownloadManager.Request(Uri.parse(url))
        .setAllowedOverMetered(false)
        .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
        .setDestinationInExternalFilesDir(context, null, "models/$fileName")
    // Never call setDestinationInExternalPublicDir(DIRECTORY_DOWNLOADS, fileName)
    val dm = context.getSystemService(DownloadManager::class.java)
    return dm.enqueue(request)
}

fun isCompleteModel(file: File, expectedSha256: String): Boolean {
    if (!file.exists() || file.length() == 0L) return false
    val digest = MessageDigest.getInstance("SHA-256")
    file.inputStream().use { input ->
        val buf = ByteArray(8192)
        while (true) {
            val n = input.read(buf)
            if (n <= 0) break
            digest.update(buf, 0, n)
        }
    }
    val actual = digest.digest().joinToString("") { "%02x".format(it) }
    return actual.equals(expectedSha256, ignoreCase = true)
}
Enter fullscreen mode Exit fullscreen mode

If you must use WorkManager instead of DownloadManager, write to the same private directory and delete the temp name in finally. Do not let a retry promote a partial file into MediaStore.Downloads because the worker lost its foreground window.

iOS destination

Put weights in Application Support, mark the directory excluded from backup, and refuse to load a file that failed checksum after a kill. Documents looks convenient during a demo, and that convenience is how Files.app grows a copy of your speech or vision weights. Complete file protection is not a substitute for the backup exclusion flag, so you set both.

// Proposed helper — unexecuted example.
func modelDirectory() throws -> URL {
    let base = try FileManager.default.url(
        for: .applicationSupportDirectory,
        in: .userDomainMask,
        appropriateFor: nil,
        create: true
    )
    let dir = base.appendingPathComponent("models", isDirectory: true)
    try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
    var values = URLResourceValues()
    values.isExcludedFromBackup = true
    var mutable = dir
    try mutable.setResourceValues(values)
    return dir
}

func persistModel(data: Data, name: String, expectedSHA256: String) throws -> URL {
    let dest = try modelDirectory().appendingPathComponent(name)
    let tmp = dest.appendingPathExtension("tmp")
    try data.write(to: tmp, options: [.atomic, .completeFileProtection])
    let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
    guard digest == expectedSHA256.lowercased() else {
        try? FileManager.default.removeItem(at: tmp)
        throw NSError(domain: "model", code: 1)
    }
    _ = try FileManager.default.replaceItemAt(dest, withItemAt: tmp)
    return dest
}
Enter fullscreen mode Exit fullscreen mode

React Native glue

Juniors often inherit react-native-fs samples that default to the downloads collection because those samples were written for user-picked photos. Pin the path to DocumentDirectoryPath on iOS and to internal files on Android, then promote a .tmp file only after the hash matches. Set background: false on this first PR unless you already have a tested background transfer policy for killed downloads.

import RNFS from 'react-native-fs';

export const modelDir = `${RNFS.DocumentDirectoryPath}/models`;

export async function downloadModel(url, fileName, expectedSha256) {
  await RNFS.mkdir(modelDir);
  const dest = `${modelDir}/${fileName}`;
  const tmp = `${dest}.tmp`;
  const result = await RNFS.downloadFile({
    fromUrl: url,
    toFile: tmp,
    background: false,
    discretionary: false,
  }).promise;
  if (result.statusCode !== 200) {
    await RNFS.unlink(tmp).catch(() => {});
    throw new Error(`download failed: ${result.statusCode}`);
  }
  const sha = await RNFS.hash(tmp, 'sha256');
  if (sha.toLowerCase() !== expectedSha256.toLowerCase()) {
    await RNFS.unlink(tmp);
    throw new Error('checksum mismatch; refusing to promote shard');
  }
  if (await RNFS.exists(dest)) await RNFS.unlink(dest);
  await RNFS.moveFile(tmp, dest);
  return dest;
}
Enter fullscreen mode Exit fullscreen mode

Flutter path on the same first PR

Flutter teams hit the same handoff with path_provider when a tutorial writes weights into getDownloadsDirectory(). Use getApplicationSupportDirectory() for the durable model, write a sibling .tmp, and delete that sibling if the isolate dies. Do not request photos or media permissions for a file the UI never shows to the user.

// Proposed helper — unexecuted example.
Future<File> modelFile(String name) async {
  final support = await getApplicationSupportDirectory();
  final dir = Directory('${support.path}/models');
  if (!await dir.exists()) {
    await dir.create(recursive: true);
  }
  return File('${dir.path}/$name');
}
Enter fullscreen mode Exit fullscreen mode

First rollback: leftover shards are the real regression

A config rollback that only swaps a remote URL is incomplete for an on-device packager. You also need to refuse truncated files and delete temp names that the previous build created in private storage. Public leftovers are worse, because MediaStore keeps serving them after your feature flag goes dark.

export async function rollbackModels(keepName) {
  if (!(await RNFS.exists(modelDir))) return;
  const entries = await RNFS.readDir(modelDir);
  for (const entry of entries) {
    const stale =
      entry.name.endsWith('.tmp') ||
      entry.name.endsWith('.part') ||
      entry.name !== keepName;
    if (stale) {
      await RNFS.unlink(entry.path);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Run the first-hour experiment again after that rollback lands on the same device. Confirm Files and MediaStore still show nothing for the old name, even when you search by extension only. Confirm the app does not load keepName if the checksum no longer matches the rolled-back manifest. Confirm a killed download does not leave a world-readable notification attachment with the model filename still visible.

If the old shard silently remains, that is a ship blocker even when the chat UI looks healthy. Write the listing into the rollback ticket, and keep the PR focused on deletion plus the load-time checksum rather than on a new model architecture.

Review the downloader without pasting device logs

Onboarding week is when people paste adb logcat and full path dumps into a public chat window. Those dumps include install paths, account names, and sometimes the model URL with a query token still attached. You want a second reader on the storage helper, not a copy of the bugreport traveling through someone else's retention policy.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source coding assistant with free model access and a free server option, which is enough for a junior to review a redacted downloader on a workspace you control. Paste the destination helper, the checksum function, and the rollback cleaner. Do not paste sysdiagnose output, Logcat, or a raw content query that includes other apps' files.

A useful prompt for that review looks like the block below, and you should keep the device listing out of it.

You are reviewing a mobile on-device model downloader.
Flag any path that lands in public Downloads, MediaStore, or user-visible Files.
Flag missing checksums, missing .tmp cleanup, and backup exclusion flags.
Assume Android 15 and iOS 18. Do not suggest broad storage permissions.
Enter fullscreen mode Exit fullscreen mode

The assistant is a reviewer, not a substitute for the Files and adb steps above. If the model names a public directory, you still run the search yourself on the handset you used for onboarding.

Decision table for the first PR

Signal you see in the first hour Ship the PR? Required change
Shard in /sdcard/Download or Files No Private dir plus deletion of public copies
.tmp next to a loadable model No Refuse load until checksum matches
Broad media permission only for models No Drop the permission; use app storage
Rollback leaves old URL file on disk No Rollback job deletes non-keep names
Private dir, checksum, no Files hit Yes Add the experiment to the PR template

Use the table during review so the discussion stays on filesystem side effects. Do not expand the PR into a cloud-versus-edge architecture debate while public shards still exist.

Limitations and who should skip this

This approach is for apps that actually download or unpack on-device weights on phones you can hold. You should not treat the snippets as OEM-certified, and you should not copy them into a web-only agent that never writes a shard. Skip or adapt the workflow when any of the following is true for your repo.

  • Your model is compiled into the App Bundle or IPA and never hits the network after install.
  • You already stream embeddings from a server and store nothing durable on the device.
  • Your compliance team requires MDM-managed volumes that this article does not describe.
  • You cannot reproduce Files or MediaStore access on the training devices you own.

This article does not claim battery numbers, download speeds, or that every Android skin hides getExternalFilesDir equally well. Samsung, Xiaomi, and other OEM Files apps can still surface app-specific external files after a search. Re-run the listing on the device class your users actually hold, and record that OEM name beside the OS version.

What to send back

If you run the experiment, reply with the device, OS, framework version, and the lifecycle transition you used. Say whether the leftover shard recovered, restarted from zero, or silently remained after the first rollback. That single-device note is more useful than a generic mobile-AI architecture thread, and it tells the next junior whether your private-directory handoff actually survived process death.

Top comments (0)