Persistent Load Workflow

In this segment, you’ll implement file-based persistence using JSON serialization. By the end, your task manager will load tasks from disk automatically on startup, proving that data survives app restarts. You’ll learn:

  • How Swift’s Codable protocol enables automatic JSON serialization
  • File I/O with FileManager across iOS and Android platforms
  • The Result type pattern for explicit, type-safe error handling
  • StateFlow synchronization between Swift and Kotlin

You’ll get started by understanding how JSON serialization works.

Understanding JSON Serialization with Codable

Before you can save tasks to disk, you need a way to convert Swift objects into a format that can be written to a file. That format is JSON (JavaScript Object Notation), a human-readable text format that’s widely supported across programming languages and platforms.

Swift provides the Codable protocol to make this conversion automatic. When a type conforms to Codable, Swift can encode it to JSON and decode it back without you writing manual parsing logic.

What is Codable?

Codable is actually a combination of two protocols:

  • Encodable: Converts Swift objects → JSON data
  • Decodable: Converts JSON data → Swift objects

When you declare struct Task: Codable, you’re telling Swift: “This type can be automatically converted to and from JSON.” Swift’s compiler generates the encoding/decoding logic for you, as long as all the type’s properties are also Codable.

Why JSON for this project?

  1. Human-readable: You can open tasks.json in a text editor and inspect your data.
  2. Cross-platform: Works seamlessly between Swift and Kotlin (which you’ll see in Section 5).
  3. Built-in support: Swift’s JSONEncoder and JSONDecoder handle the heavy lifting.
  4. Type-safe: Codable catches type mismatches at compile time.

Task Model Review

Your Task struct is already set up for JSON serialization. Open Task.swift and you’ll see:

public struct Task: Codable {
  let id: String
  let title: String
  let description: String
  let priority: Priority
  var isCompleted: Bool
  let photoFilename: String?
}

Notice the Codable conformance after the struct name. Because all of these properties’ types are themselves Codable (String, Bool, and Optional types all conform), Swift automatically synthesizes the encoding/decoding logic.

The Priority enum also conforms to Codable:

public enum Priority: String, Codable {
  case low, medium, high
}

By specifying String as the raw type, Swift will encode Priority.high as the JSON string "high" instead of a raw integer. This makes the JSON more readable and maintainable.

How Encoding and Decoding Work

To convert objects to JSON, you use JSONEncoder:

let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted  // Makes JSON human-readable
let data = try encoder.encode(tasks)       // [Task] → Data

The encode(_:) method returns a Data object containing the JSON bytes. You can write this Data directly to a file using FileManager.

To convert JSON back to objects, you use JSONDecoder:

let data = try Data(contentsOf: fileURL)            // Read file
let decoder = JSONDecoder()
let tasks = try decoder.decode([Task].self, from: data)  // Data → [Task]

The decode(_:from:) method requires you to specify the expected type ([Task].self). Swift verifies at compile time that Task conforms to Decodable, preventing runtime type errors.

Both encoding and decoding can throw errors:

  • EncodingError: The object contains non-encodable data
  • DecodingError: The JSON structure doesn’t match the expected type
  • Other errors: File I/O failures, permission issues, etc.

You’ll handle these errors using Swift’s Result type, which makes error handling explicit and type-safe. Let’s see how this works in practice by creating TaskStorage.

Creating TaskStorage for Load Operations

The TaskStorage class is responsible for all file I/O operations. It handles reading tasks from disk, writing tasks to disk, and managing the file path across iOS and Android platforms.

In this section, you’ll implement just the load operation: reading tasks from a file. You’ll add the save operation in the next segment, but starting with loading keeps the initial implementation focused and manageable.

Creating the StorageError Enum

First, you’ll define custom error types that describe what went wrong during file operations.

Create a new file TaskStorage.swift in taskmanager-lib/Sources/TaskManagerKit/. Add this code:

import Foundation

enum StorageError: Error {
  case fileNotFound
  case corruptedData
  case encodingFailed
  case decodingFailed
  case writeFailed(Error)
  case readFailed(Error)
}

What this does:

  • Custom error enum: Defines specific error cases for debugging
  • fileNotFound: The tasks.json file doesn’t exist yet (expected on first app launch).
  • corruptedData: The file exists but contains invalid JSON.
  • encodingFailed: Failed to convert tasks to JSON (shouldn’t happen with Codable).
  • decodingFailed: Failed to convert JSON to tasks (malformed data).
  • writeFailed(Error): File system error during write operation.
  • readFailed(Error): File system error during read operation.

By creating distinct error cases, you can handle each scenario appropriately. For example, fileNotFound on first launch isn’t an error. It’s expected behavior. But corruptedData indicates something went wrong and the app should handle it gracefully.

Creating the TaskStorage Class

Next, you’ll create the class structure and set up the file path.

Add this code below the StorageError enum:

// 1
class TaskStorage {
  
  // 2
  private let fileURL: URL
  
  // 3
  public init(filename: String = "tasks.json") {
    #if os(Android)
    // Android: Use app's files directory
    let documentsPath = "/data/data/com.kodeco.android.swiftsdkforandroid.taskmanager/files"
    self.fileURL = URL(fileURLWithPath: documentsPath).appendingPathComponent(filename)
    #else
    // iOS/macOS: Use documents directory
    let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
    self.fileURL = documentsDirectory.appendingPathComponent(filename)
    #endif
  }
}

This code has the following:

  1. TaskStorage class: This class encapsulates all file I/O logic. By keeping file operations separate from TaskManager, you follow the single responsibility principle. TaskManager handles business logic (adding/removing tasks), while TaskStorage handles persistence.

  2. fileURL property: Stores the complete path to tasks.json. Marking it private prevents external code from accidentally changing the file location.

  3. Initializer: Sets up the file path using platform-specific directories. The #if os(Android) conditional compilation checks which platform you’re building for:

    • Android: You must hardcode the app’s files directory because FileManager.SearchPathDirectory doesn’t work on Android with the Swift SDK.
    • iOS/macOS: You use FileManager.default.urls(for:in:) to get the standard documents directory.

The default parameter filename: String = "tasks.json" means you can create a storage instance with TaskStorage() and it automatically uses "tasks.json". This also makes testing easier. You can pass a different filename for test cases.

Implementing the loadTasks() Method

Now for the core functionality: reading tasks from disk.

Add this method inside the TaskStorage class:

public func loadTasks() -> Result<[Task], StorageError> {
  guard FileManager.default.fileExists(atPath: fileURL.path) else {
    return .failure(.fileNotFound)
  }
  
  do {
    let data = try Data(contentsOf: fileURL)
    
    guard !data.isEmpty else {
      return .success([])
    }
    
    let decoder = JSONDecoder()
    let tasks = try decoder.decode([Task].self, from: data)
    
    return .success(tasks)
  } catch let error as DecodingError {
    return .failure(.corruptedData)
  } catch {
    return .failure(.readFailed(error))
  }
}

Here’s what loadTasks() is doing:

  • File existence check: Uses FileManager.default.fileExists(atPath:) to check if tasks.json exists. If not, returns .failure(.fileNotFound). This is expected on first launch when no tasks have been saved yet.

  • Load file data: Data(contentsOf:) reads the entire file into memory as a Data object. This is a throwing operation. If the file can’t be read (permissions issue, disk full, etc.), it throws an error.

  • Empty file handling: Checks if the file is empty with guard !data.isEmpty. An empty file isn’t valid JSON, so instead of letting the decoder throw an error, you return .success([]), an empty task array. This handles edge cases gracefully.

  • JSON decoding: Creates a JSONDecoder and calls decode([Task].self, from: data) to convert the JSON bytes into a Swift array. The [Task].self syntax tells the decoder what type to expect.

  • Type-safe decoding: Because you specified [Task].self, Swift ensures at compile time that Task conforms to Decodable. If the JSON structure doesn’t match Task’s properties (e.g., missing "title" field), the decoder throws a DecodingError.

  • Error differentiation: The catch blocks separate decoding errors from general errors:

    • catch let error as DecodingError catches JSON structure problems → returns .corruptedData.
    • catch (without type) catches all other errors → returns .readFailed(error), preserving the underlying error for debugging.

Using the Result Type Pattern

Notice the return type: Result<[Task], StorageError>. This is Swift’s built-in Result type, which represents either success or failure:

  • .success([Task]): Contains the loaded tasks array.
  • .failure(StorageError): Contains a specific error case.

This pattern makes error handling explicit. The caller can’t ignore errors because they’re part of the return value. Compare this to exceptions, which can be thrown anywhere and might go uncaught. With Result, the compiler forces you to handle both success and failure cases.

You’ll see how to use this pattern in the next section when you integrate TaskStorage into TaskManager.

Save the file. Your TaskStorage class is complete for loading operations. In the next section, you’ll connect it to TaskManager so tasks are loaded automatically on app startup.

Integrating TaskStorage into TaskManager

Now that TaskStorage can load tasks from disk, you need to wire it up to TaskManager so tasks are loaded automatically when the app starts.

The integration is straightforward: add a storage property to TaskManager and call loadTasks() during initialization. Because TaskManager uses the singleton pattern (accessed via TaskManager.shared), initialization happens only once per app lifetime, perfect for loading persistent data.

Adding the Storage Property

Open taskmanager-lib/Sources/TaskManagerKit/TaskManager.swift. Find the class declaration and add the storage property:

public class TaskManager: @unchecked Sendable {
  private var tasks: [Task] = []
  private let storage = TaskStorage()  // NEW

This creates a TaskStorage instance that belongs to TaskManager. By making it private, you ensure that only TaskManager can access the storage layer. External code (like your Kotlin app) can’t bypass TaskManager and manipulate files directly.

Loading Tasks on Initialization

Find the private init() method and modify it to load tasks:

private init() {
  if case .success(let loadedTasks) = storage.loadTasks() {
    self.tasks = loadedTasks
  }
}

The init() does:

  1. Automatic loading: When TaskManager.shared is first accessed, Swift calls init(), which immediately attempts to load tasks from disk.

  2. Pattern matching with if case: The if case .success(let loadedTasks) = syntax unwraps the Result type. If loadTasks() returns .success, it extracts the tasks array and assigns it to self.tasks. If it returns .failure, the condition fails and nothing happens.

  3. Silent failure handling: Notice there’s no else clause. If loading fails (file not found, corrupted data, etc.), the app continues with an empty task list. This is intentional:

    • First launch: fileNotFound is expected. There’s no tasks.json yet.
    • Corrupted data: Better to start fresh than crash the app.
    • Production readiness: Users should never see technical errors for recoverable problems.

This graceful degradation approach is common in production apps. Log the error for debugging (in a real app), but don’t block the user from using the app.

Note: In a production app, you’d typically log errors to a crash reporting service (like Firebase Crashlytics) so you can investigate issues. But for this learning project, silent failure keeps the code focused on the core functionality. The user can always create new tasks if loading fails.

Save the file, clean the project under Build -> Clean Project first. Then build again in Android Studio. Or if you want to build just the taskmanager-lib module:

./gradlew taskmanager-lib:clean 
cd taskmanager-lib
swift build

You should see a clean build with no errors. Tasks will load automatically on startup. Next, you’ll add a method to expose tasks as JSON to Kotlin.

Adding getAllTasksJSON() to TaskManager

For Kotlin to access the loaded tasks, you need to add a method that returns tasks as a JSON string. This method will be automatically exposed to Kotlin through swift-java.

Action: Open TaskManager.swift and add this method:

public static func getAllTasksJSON() -> String {
  let encoder = JSONEncoder()
  guard let jsonData = try? encoder.encode(shared.tasks),
        let jsonString = String(data: jsonData, encoding: .utf8) else {
    return "[]"
  }
  return jsonString
}

This function will:

  • Create an encoder: JSONEncoder() will convert the tasks array to JSON
  • Encode tasks: encoder.encode(shared.tasks) serializes the in-memory tasks array (which was loaded by init())
  • Convert to string: String(data:encoding:) converts the JSON data to a UTF-8 string that Kotlin can receive
  • Handle errors: If encoding fails, returns an empty array "[]" instead of crashing

This method is marked public static so swift-java can generate a Java binding that Kotlin can call. The static keyword means you call it as TaskManager.getAllTasksJSON() without needing an instance.

Save and rebuild to verify the method compiles:

cd taskmanager-lib
swift build

Your Swift persistence layer is now complete. Next, you’ll wire up the Kotlin side to parse the JSON and display the tasks.

Kotlin Repository Integration

The Swift side now loads tasks from disk, but your Kotlin app doesn’t know about them yet. In this section, you’ll connect the Kotlin TaskRepository to Swift’s TaskManager so tasks flow from the JSON file → Swift → Kotlin → UI.

Understanding the Architecture

Before diving into code, let’s clarify the data flow:

  • App startup: Android launches your app, which initializes TaskRepository (it’s a singleton object).

  • Repository initialization: TaskRepository’s init block runs, calling loadTasks().

  • Swift bridge call: loadTasks() calls Swift’s TaskManager.getAllTasksJSON(), which returns a JSON string.

  • JSON parsing: Kotlin parses the JSON string into Task objects.

  • StateFlow update: Kotlin assigns the parsed tasks to _tasks.value.

  • UI refresh: Your Compose UI observes _tasks StateFlow and automatically re-renders.

This architecture uses JSON as the bridge format between Swift and Kotlin. While you could pass Swift objects directly using swift-java bindings, JSON provides a clear boundary and makes debugging easier (you can inspect the JSON string).

Implementing loadTasks() in Kotlin

Open app/src/main/java/com/kodeco/android/swiftsdkforandroid/taskmanager/repository/TaskRepository.kt. Add this method inside the TaskRepository object:

private fun loadTasks() {
  try {
    // Get tasks as JSON from Swift
    val jsonString = TaskManager.getAllTasksJSON()
    val jsonArray = JSONArray(jsonString)
    
    val loadedTasks = mutableListOf<Task>()
    for (i in 0 until jsonArray.length()) {
      val jsonTask = jsonArray.getJSONObject(i)
      
      // Parse priority string
      val priorityString = jsonTask.getString("priority")
      val priority = when (priorityString.lowercase()) {
        "low" -> Priority.low(arena)
        "medium" -> Priority.medium(arena)
        "high" -> Priority.high(arena)
        else -> Priority.medium(arena)
      }
      
      val task = Task.init(
        jsonTask.getString("id"),
        jsonTask.getString("title"),
        jsonTask.getString("description"),
        priority,
        jsonTask.getBoolean("isCompleted"),
        Optional.empty(),  // Photo handling in next segment
        arena
      )
      loadedTasks.add(task)
    }
    
    _tasks.value = loadedTasks
  } catch (e: Exception) {
    e.printStackTrace()
    _tasks.value = emptyList()
  }
}

This code:

  • Calls Swift: TaskManager.getAllTasksJSON() is a Swift method exposed to Kotlin via JNI (Java Native Interface). It returns all tasks as a JSON string like [{"id":"123","title":"Buy groceries",...},...].

  • Parses JSON array: JSONArray(jsonString) uses Android’s built-in org.json library to parse the string into a structured array. This library is part of the Android SDK, so you don’t need to add dependencies.

  • Iterates tasks: The for (i in 0 until jsonArray.length()) loop extracts each task object from the array. getJSONObject(i) returns a JSONObject representing one task.

  • Priority mapping: Swift encodes Priority.high as the string "high" in JSON. You need to convert this back to a Swift Priority enum instance using the swift-java API.

  • Creates Task instance: Task.init(...) calls Swift’s Task initializer through the swift-java bridge. Each parameter maps to a property.

  • Updates StateFlow: _tasks.value = loadedTasks assigns the parsed tasks to the StateFlow. Because your Compose UI observes this StateFlow, the UI automatically re-renders with the loaded tasks.

Note: The catch block handles any parsing errors (malformed JSON, missing fields, etc.) by printing the stack trace (useful for debugging during development) and setting an empty task list. This follows the same graceful degradation pattern you used in Swift: don’t crash, just start with an empty state.

Adding the Init Block

Now you need to call loadTasks() automatically when the repository is initialized.

Action: Find the TODO comment in TaskRepository and add the init block:

object TaskRepository {
  private val _tasks = MutableStateFlow<List<Task>>(emptyList())
  val tasks: StateFlow<List<Task>> = _tasks
  
  private val arena = SwiftArena.ofAuto()
  private val manager = TaskManager.getShared(arena)
  
  // Add this init block:
  init {
    loadTasks()
  }

The init block runs when TaskRepository is first accessed. Because TaskRepository is a Kotlin object (singleton), this happens exactly once per app lifetime, when Android first references TaskRepository.tasks from your Compose UI.

This timing is perfect: by the time your UI needs to display tasks, they’re already loaded from disk.

Save the file and rebuild the app in Android Studio or with:

./gradlew :app:assembleDebug

You should see a successful build. Your persistence system is now connected end-to-end: Swift loads JSON from disk, Kotlin parses it, and the UI displays it. Time to test!

Verifying the Persistence Layer

Now that you’ve got the code wired up, you’ll go through and see it in action!

Begin with a fresh app install (or clear app data to simulate first launch).

Next, launch the app on your Android device or emulator and create 3 tasks:

  • Title: “Buy groceries”, Description: “Milk, eggs, bread”, Priority: High
  • Title: “Read book”, Description: “Finish chapter 5”, Priority: Medium
  • Title: “Call dentist”, Description: “Schedule checkup”, Priority: Low

Verify all three tasks appear in the task list with correct priorities (colored badges). Completely close the app by swiping it away from Android’s recent apps screen (don’t just press home). Now reopen the app.

You should see all three tasks appear in the task list with the tasks displayed in the same order as they were created!

Verify Persistence Layer
Verify Persistence Layer

If you want to inspect what’s happening under the hood, you can use Android Debug Bridge (adb):

# View the tasks.json file contents
adb shell cat /data/data/com.kodeco.android.swiftsdkforandroid.taskmanager/files/tasks.json

You should see JSON like:

[
  {
    "id": "uuid-here",
    "title": "Buy groceries",
    "description": "Milk, eggs, bread",
    "priority": "high",
    "isCompleted": false,
    "photoFilename": null
  },
  ...
]

This confirms Swift successfully serialized your tasks with proper formatting.

Key Takeaways

You’ve built a robust persistence layer that loads tasks from disk automatically. Here’s what you learned:

  • Swift’s Codable protocol enables automatic JSON serialization with minimal code. Just add Codable conformance and Swift generates the encoding/decoding logic.
  • FileManager provides cross-platform file I/O, but Android requires explicit file paths because FileManager.SearchPathDirectory doesn’t work with the Swift SDK for Android.
  • The Result type pattern makes error handling explicit and type-safe. The compiler forces you to handle both success and failure cases, preventing silent bugs.
  • Separation of concerns keeps your code maintainable: TaskStorage handles file operations, TaskManager handles business logic. Each class has a single responsibility.
  • StateFlow provides reactive UI updates. Change _tasks.value in Kotlin and your Compose UI automatically refreshes. No manual UI updates needed.
  • Graceful error handling improves user experience: expected errors (like fileNotFound on first launch) are handled silently, letting the app continue normally instead of crashing.

In the next segment, you’ll implement the write operation (saveTasks()) so tasks are saved immediately when you create, update, or delete them. This completes the persistence cycle, giving you a fully functional task manager that never loses data.

See forum comments
Download course materials from Github
Previous: Introduction Next: CRUD Operations & Validation