Skillversitas

Files and Local Storage

A Kivy app that remembers anything - the recipe list, a saved setting, a downloaded file - needs local storage, and Kivy's contribution is the core.storage abstraction that resolves paths per platform (the app's data directory on Android is not ~/app on desktop) and a file dialog for the user-driven cases. The storage pattern is the same as the data-driven chapter's store, now with persistence: load on start, save on change, and keep the file format simple. This chapter covers the path resolution, the JSON store, the file dialog, and the platform notes that make the storage portable.

Path Resolution: Where Files Live

from kivy.core.storage import FileSystemStorage

# the user-writable directory, per platform:
#   desktop: a folder next to the app / the user's data dir
#   android: the app's external files dir
#   ios:     the app's documents dir
fss = FileSystemStorage()
fss.extpath = "recipes"                 # a subdirectory of the data dir
fss.makedirs()                          # create it if missing

data_path = fss.path("recipes.json")    # the full, platform-correct path
print(data_path)

FileSystemStorage resolves the app's data directory for the current platform, and fss.path("name") gives the full path to a file in it - so the same code stores to the right place on desktop, Android, and iOS without hard-coding a home directory. fss.extpath plus makedirs creates a subdirectory for the app's data. The fss object also exposes the file operations (fss.exists, fss.listdir, fss.rename) for when you need to manage the directory, but for a single data file the path() plus plain open() is the working combination. The rule: never build a path from os.path.expanduser("~") for app data on mobile - the data directory is the portable home, and FileSystemStorage is how you get it.

The JSON Store: Load and Save

import json

class RecipeStore:
    def __init__(self, path):
        self.path = path
        self.data = self._load()

    def _load(self):
        import os
        if os.path.exists(self.path):
            try:
                with open(self.path, encoding="utf-8") as f:
                    return json.load(f)
            except (json.JSONDecodeError, OSError):
                # a corrupt file should not kill the app on start:
                # back it up and start clean, the user's data is recoverable
                import shutil
                shutil.move(self.path, self.path + ".bak")
                return []
        return []

    def save(self):
        # write to a temp file, then rename: a crash mid-write
        # leaves the old file intact instead of a truncated one
        import os, tempfile
        d = os.path.dirname(self.path)
        fd, tmp = tempfile.mkstemp(dir=d, suffix=".tmp")
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                json.dump(self.data, f, indent=2)
            os.replace(tmp, self.path)     # atomic on the same filesystem
        except BaseException:
            os.unlink(tmp)
            raise

    # the data-driven chapter's add() calls save() after appending

The store is the data-driven chapter's RecipeStore with real persistence: _load reads the JSON on start (handling the missing-file and corrupt-file cases - a corrupt file is backed up and the app starts clean rather than crashing), and save writes it. The save is write-then-rename: the new content goes to a temp file, and os.replace swaps it into place atomically, so a crash mid-write leaves the previous good file instead of a truncated one that fails to parse next start. That two-line difference is the standard durable-write pattern, and it is the one that saves an app from the "my data file is empty/corrupt after a crash" bug. The format is plain JSON - inspectable, debuggable, and easy to migrate - which is the right default for app data until a real schema demands a database (the Peewee tutorial, for a structured case).

The File Dialog: User-Driven Files

from kivy.uix.popup import Popup
from kivy.lang import Builder

# the FileChooser is a Kivy widget - a grid of files with a path
Builder.load_string("""
FileChooserLayout:
    id: filechooser
    show_hidden_file: False
    on_selection: app.on_file_selected(absolute_path=True)
""")

class ImportApp:
    def __init__(self):
        self.fcs = Builder.load_string(
            "FileChooserLayout: show_hidden_file: False")
        self.popup = Popup(title="Import a recipe file",
                           content=self.fcs,
                           size_hint=(0.9, 0.9))

    def pick(self):
        self.fcs.selector = "file"
        self.popup.open()

    def on_file_selected(self, instance, selection, absolute=False):
        if selection:
            path = selection[0]
            self.popup.dismiss()
            self.import_file(path)   # read the chosen file

When the user chooses the file (import a recipe JSON, open an image), the FileChooserLayout is the Kivy-native dialog: a widget that lists a directory with selection, wrapped in a Popup for the modal. on_selection fires with the chosen path(s) (the absolute_path flag gives the full path), the popup dismisses, and the app reads the file. The selector switches between file and folder mode. This is the user-driven complement to the app's own data file - the app manages its store, the dialog lets the user bring data in (or send it out, via the same picker pointed at export). The pattern is the same as the rest of the app: the dialog is a UI component, the file read is the data operation, and the result flows into the store like any other data change.

Platform Notes

  • Android external storage is scoped per app - FileSystemStorage points at the app's own directory, which is why hard-coded /sdcard/... paths fail on modern Android. Use the resolved path, always.
  • iOS sandbox gives each app its own container; the data directory is inside it, and files are not visible to the user's Files app by default (use the sharing/reactive APIs for export). The resolved path is the only correct one.
  • Desktop has the most freedom, but still prefer the app's data directory over the home root - it keeps the app's files together and out of the way, and it is the same code path as the mobile builds.
  • Large blobs (images, videos) do not belong in the JSON store - store the bytes as separate files in the data directory and keep only the path in the JSON. The store is for the structured data; the files are for the blobs, and the path is the link.

The Working Rules

  • Resolve paths with FileSystemStorage - never hard-code a home or /sdcard path for app data.
  • Load on start, save on change, and make the load tolerant (a missing or corrupt file starts clean, with a backup, not a crash).
  • Write durably: temp file plus os.replace, so a crash cannot truncate the live data file.
  • Keep the format simple (JSON) and inspectable, until a real schema demands a database.
  • Blobs as files, paths in the store - the JSON holds the structured data and references; the bytes live as separate files.

Summary

Store app data with the resolved path from FileSystemStorage (the platform-correct data directory, never a hard-coded home or /sdcard), in a simple JSON store that loads on start (tolerant of missing or corrupt files, with a backup) and saves on change with the durable write-then-os.replace pattern so a crash cannot truncate the live file. For user-driven files, the FileChooserLayout in a Popup is the native picker - on_selection gives the path, the app reads it, and the result flows into the store like any other data change. Keep the structured data in the JSON and the blobs as separate files referenced by path. The pattern is the data-driven store with persistence and portability added - the app remembers, across restarts and across platforms, without the storage layer becoming a source of crashes.