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
Codableprotocol enables automatic JSON serialization - File I/O with
FileManageracross iOS and Android platforms - The
Resulttype 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?
-
Human-readable: You can open
tasks.jsonin a text editor and inspect your data. - Cross-platform: Works seamlessly between Swift and Kotlin (which you’ll see in Section 5).
-
Built-in support: Swift’s
JSONEncoderandJSONDecoderhandle the heavy lifting. - 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: Thetasks.jsonfile 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 withCodable). -
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:
-
TaskStorage class: This class encapsulates all file I/O logic. By keeping file operations separate from
TaskManager, you follow the single responsibility principle.TaskManagerhandles business logic (adding/removing tasks), whileTaskStoragehandles persistence. -
fileURL property: Stores the complete path to
tasks.json. Marking itprivateprevents external code from accidentally changing the file location. -
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.SearchPathDirectorydoesn’t work on Android with the Swift SDK. -
iOS/macOS: You use
FileManager.default.urls(for:in:)to get the standard documents directory.
-
Android: You must hardcode the app’s files directory because
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 iftasks.jsonexists. 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 aDataobject. 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
JSONDecoderand callsdecode([Task].self, from: data)to convert the JSON bytes into a Swift array. The[Task].selfsyntax tells the decoder what type to expect. -
Type-safe decoding: Because you specified
[Task].self, Swift ensures at compile time thatTaskconforms toDecodable. If the JSON structure doesn’t matchTask’s properties (e.g., missing"title"field), the decoder throws aDecodingError. -
Error differentiation: The catch blocks separate decoding errors from general errors:
-
catch let error as DecodingErrorcatches 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:
-
Automatic loading: When
TaskManager.sharedis first accessed, Swift callsinit(), which immediately attempts to load tasks from disk. -
Pattern matching with if case: The
if case .success(let loadedTasks) =syntax unwraps theResulttype. IfloadTasks()returns.success, it extracts the tasks array and assigns it toself.tasks. If it returns.failure, the condition fails and nothing happens. -
Silent failure handling: Notice there’s no
elseclause. If loading fails (file not found, corrupted data, etc.), the app continues with an empty task list. This is intentional:-
First launch:
fileNotFoundis expected. There’s notasks.jsonyet. - Corrupted data: Better to start fresh than crash the app.
- Production readiness: Users should never see technical errors for recoverable problems.
-
First launch:
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 byinit()) -
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 singletonobject). -
Repository initialization:
TaskRepository’sinitblock runs, callingloadTasks(). -
Swift bridge call:
loadTasks()calls Swift’sTaskManager.getAllTasksJSON(), which returns a JSON string. -
JSON parsing: Kotlin parses the JSON string into
Taskobjects. -
StateFlow update: Kotlin assigns the parsed tasks to
_tasks.value. -
UI refresh: Your Compose UI observes
_tasksStateFlow 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-inorg.jsonlibrary 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 aJSONObjectrepresenting one task. -
Priority mapping: Swift encodes
Priority.highas the string"high"in JSON. You need to convert this back to a SwiftPriorityenum instance using the swift-java API. -
Creates Task instance:
Task.init(...)calls Swift’sTaskinitializer through the swift-java bridge. Each parameter maps to a property. -
Updates StateFlow:
_tasks.value = loadedTasksassigns the parsed tasks to the StateFlow. Because your Compose UI observes this StateFlow, the UI automatically re-renders with the loaded tasks.
Note: The
catchblock 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!
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
Codableconformance and Swift generates the encoding/decoding logic. -
FileManager provides cross-platform file I/O, but Android requires explicit file paths because
FileManager.SearchPathDirectorydoesn’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:
TaskStoragehandles file operations,TaskManagerhandles business logic. Each class has a single responsibility. -
StateFlow provides reactive UI updates. Change
_tasks.valuein Kotlin and your Compose UI automatically refreshes. No manual UI updates needed. -
Graceful error handling improves user experience: expected errors (like
fileNotFoundon 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.