CRUD Operations & Validation
In the last segment, you implemented loading tasks from disk. The app now remembers tasks after restart. However, there’s still a problem: you can’t save changes.
The missing piece is to write operations utilizing CRUD (Create, Read, Update, Delete). In this segment, you’ll complete the persistence layer by:
- Adding
saveTasks()to write tasks to disk. - Implementing
updateTask()anddeleteTask()in TaskManager with validation. - Creating JNI exports to bridge Swift methods to Kotlin.
By the end, you’ll have a complete CRUD system where every change persists immediately to disk.
Understanding the CRUD Pattern
CRUD is a standard acronym in software development representing the four fundamental operations for managing data:
- Create: Add new records.
- Read: Retrieve existing records.
- Update: Modify existing records.
- Delete: Remove records.
You’ve already implemented parts of CRUD:
-
Create:
TaskManager.addTask()adds tasks (exists in Starter). -
Read:
TaskStorage.loadTasks()retrieves tasks (implemented in Segment 02).
Now you’ll complete the pattern with Update and Delete, plus the persistence layer that makes all operations permanent.
Why CRUD Matters
CRUD provides a standard vocabulary for data operations. When you say “implement CRUD,” other developers immediately understand you’re building Create, Read, Update, and Delete functions. This consistency makes codebases easier to understand and maintain.
Each CRUD operation has distinct responsibilities:
- Create/Update must validate data before persistence.
- Read must handle missing or corrupted data gracefully.
- Delete must clean up related resources (like photo files).
- All mutations must persist changes immediately for reliability.
Architecture Overview
The data flow for mutations follows this path:
Kotlin UI → Repository → JNI Bridge → TaskManager → TaskStorage → tasks.json
When a user edits a task in the UI:
- Compose UI triggers
TaskRepository.updateTask() - Repository calls JNI export
updateTaskJNI() - JNI export converts types and calls
TaskManager.updateTask() - TaskManager validates data and updates in-memory array
- TaskManager calls
TaskStorage.saveTasks() - TaskStorage writes JSON to disk
- Repository calls
loadTasks()to refresh StateFlow - UI observes StateFlow and re-renders
This layered architecture provides clear separation: UI doesn’t know about Swift, Swift doesn’t know about UI, and each layer can be tested independently.
Completing TaskStorage with saveTasks()
Earlier in the lesson, you implemented loadTasks() to read tasks from disk. Now you’ll complete TaskStorage by adding the write operation.
The pattern mirrors loadTasks(): use Swift’s Codable protocol for serialization, return a Result type for error handling, and write to the same fileURL you read from.
Implementing saveTasks()
Open taskmanager-lib/Sources/TaskManagerKit/TaskStorage.swift. Add this method after loadTasks():
public func saveTasks(_ tasks: [Task]) -> Result<Void, StorageError> {
do {
// 1
let encoder = JSONEncoder()
// 2
encoder.outputFormatting = .prettyPrinted
// 3
let data = try encoder.encode(tasks)
// 4
try data.write(to: fileURL, options: [.atomic])
// 5
return .success(())
// 6
} catch let error as EncodingError {
return .failure(.encodingFailed)
} catch {
return .failure(.writeFailed(error))
}
}
Here’s what the function does:
-
Create encoder:
JSONEncoder()is Swift’s built-in encoder for converting objects to JSON data. It’s the complement toJSONDecoderyou used inloadTasks(). -
Pretty printing:
encoder.outputFormatting = .prettyPrintedformats the JSON with indentation and newlines, making it human-readable. During development, this makes debugging easier. You can opentasks.jsonin a text editor and inspect its contents. -
Encode tasks:
encoder.encode(tasks)converts the[Task]array toData(raw bytes). BecauseTaskconforms toCodable, the encoder automatically serializes all properties. -
Atomic write:
data.write(to:options:)writes the bytes to disk. The.atomicoption tellsFileManagerto write to a temporary file first, then rename it to the final name. This prevents corruption if the app crashes mid-write. -
Success return:
return .success(())indicates successful write. The()is Swift’s empty tuple (Void). There’s no data to return, just success/failure status. -
Error handling: The catch blocks differentiate between encoding errors (shouldn’t happen with
Codable, but handles edge cases) and file system errors (disk full, permissions, etc.).
Understanding Atomic Writes
The .atomic option deserves special attention because it’s crucial for data integrity:
Without .atomic:
- Start writing to
tasks.json. - App crashes halfway through.
- File contains partial, corrupted JSON.
- Next launch:
loadTasks()fails with.corruptedData.
With .atomic:
- Write to temporary file
tasks.json.tmp. - App crashes. Temporary file is corrupted, but original
tasks.jsonis intact. - On successful write, rename
tasks.json.tmp→tasks.json(atomic operation at OS level). - Next launch: Either old data (if write didn’t complete) or new data (if it did), but never corrupted data.
File system renames are atomic operations. They either succeed completely or fail completely. This guarantee is what makes atomic writes safe.
Why Pretty Printed JSON?
During development, you’ll inspect tasks.json to debug issues. Compare these two formats:
Without .prettyPrinted:
[{"id":"123","title":"Buy groceries","description":"Milk, eggs, bread","priority":"high","isCompleted":false,"photoFilename":null}]
With .prettyPrinted:
[
{
"id": "123",
"title": "Buy groceries",
"description": "Milk, eggs, bread",
"priority": "high",
"isCompleted": false,
"photoFilename": null
}
]
The second format is much easier to read. In production apps, you’d remove .prettyPrinted to reduce file size (especially with thousands of records), but for learning projects, readability trumps optimization.
Save the file. Your TaskStorage class now has complete read and write operations. Next, you’ll review the validation logic, then implement CRUD methods in TaskManager.
Understanding TaskValidator
Before adding update and delete operations, you need validation logic. The Starter project includes a TaskValidator class that centralizes validation rules. While you could put validation directly in TaskManager, separating it into a dedicated TaskValidator class provides clearer responsibilities and makes testing easier.
Why Separate Validation?
Consider what happens without a separate validator:
Without TaskValidator:
public func updateTask(_ updatedTask: Task) -> Bool {
// Validation mixed with business logic
let trimmed = updatedTask.title.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count >= 3 && trimmed.count <= 50 else {
return false
}
// ... more validation ...
// ... update logic ...
}
With TaskValidator:
public func updateTask(_ updatedTask: Task) -> Bool {
guard TaskValidator.validateTitle(updatedTask.title) else {
return false
}
// ... update logic ...
}
The second approach is cleaner. TaskManager orchestrates operations, TaskValidator handles validation rules. If you need to change validation (like minimum title length), you modify one place: TaskValidator.
Understanding TaskValidator.swift
The Starter project already includes TaskValidator.swift in Sources/TaskManagerKit/. This class was introduced in earlier lessons, so you don’t need to create it—just open it now to review how it works before using it in your CRUD operations:
import Foundation
public class TaskValidator {
// 1
private static let minTitleLength = 3
private static let maxTitleLength = 50
private static let minDescriptionLength = 10
private static let maxDescriptionLength = 200
// 2
public static func validateTitle(_ title: String) -> Bool {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.count >= minTitleLength && trimmed.count <= maxTitleLength
}
// 3
public static func validateDescription(_ description: String) -> Bool {
let trimmed = description.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.count >= minDescriptionLength && trimmed.count <= maxDescriptionLength
}
// 4
public static func validatePriority(_ priority: String) -> Bool {
return Priority(rawValue: priority) != nil
}
}
Now you’ll use this validator in TaskManager’s CRUD operations. What each section does:
-
Constants:
minTitleLength,maxTitleLength, etc. define validation rules as static constants. Using constants instead of magic numbers makes rules easy to understand and modify. -
validateTitle(): Trims whitespace from both ends using
trimmingCharacters(in: .whitespacesAndNewlines), then checks the length is between 3 and 50 characters. This prevents empty titles or overly long titles that wouldn’t fit in the UI. -
validateDescription(): Same pattern as title validation, but requires 10-200 characters. The higher minimum encourages meaningful descriptions instead of placeholders like “todo”.
-
validatePriority(): Validates that a string can be converted to the
Priorityenum. ThePriority(rawValue:)initializer returnsnilfor invalid strings, so checking!= nilconfirms validity.
Understanding Static Methods
Notice all methods are static. This means you call them on the class itself (TaskValidator.validateTitle()), not on instances (let validator = TaskValidator(); validator.validateTitle()).
Static methods make sense for validators because:
- No state: Validation rules don’t change per instance, they’re consistent across all calls.
- No initialization: You don’t need to create and manage validator instances.
-
Clear intent:
TaskValidator.validateTitle()clearly indicates a validation operation on the class level. - swift-java compatibility: Static methods are easier for swift-java to expose to Kotlin.
Now that you understand how TaskValidator works, you’ll use it in the update and delete operations to ensure data quality.
Completing TaskManager.addTask()
Before implementing update and delete, you need to complete the existing addTask() method. Currently, it has a TODO comment for saving tasks to disk.
Addressing the TODO
Open taskmanager-lib/Sources/TaskManagerKit/TaskManager.swift and locate the addTask() method:
@discardableResult
public func addTask(_ task: Task) -> Bool {
guard TaskValidator.validateTitle(task.title),
TaskValidator.validateDescription(task.description) else {
return false
}
tasks.append(task)
// TODO: Save tasks to file after adding
return true
}
The method validates and adds tasks to the in-memory array, but doesn’t persist to disk. This means new tasks disappear when the app restarts. You implemented storage.saveTasks() in the previous section, so now you can use it.
Adding Persistence
Replace the TODO comment with a call to saveTasks():
@discardableResult
public func addTask(_ task: Task) -> Bool {
guard TaskValidator.validateTitle(task.title),
TaskValidator.validateDescription(task.description) else {
return false
}
tasks.append(task)
_ = storage.saveTasks(tasks)
return true
}
_ = storage.saveTasks(tasks) writes the updated array to disk immediately after appending. The _ ignores the Result return value. Even if save fails (rare), the task is added to memory and the operation returns true.
Your addTask() method now persists tasks to disk, completing the Create operation in CRUD.
Updating Task Model for Photo Storage
Before implementing photo storage, you need to update the Task struct to reflect the proper semantics for how photos are stored.
Open taskmanager-lib/Sources/TaskManagerKit/Task.swift and look at the current structure:
public struct Task: Codable {
public let id: String
public let title: String
public let description: String
public let priority: Priority
public var isCompleted: Bool
public let photoUri: String?
// TODO: In this lesson, you'll rename photoUri to photoFilename
// and implement persistent storage via PhotoStorage
public init(
id: String,
title: String,
description: String,
priority: Priority,
isCompleted: Bool = false,
photoUri: String? = nil
) {
// ...
}
}
The field is named photoUri because in earlier lessons, photos weren’t persisted—the app stored URIs pointing to external locations. Now that you’re implementing persistent photo storage, the name should reflect what’s actually stored: a filename. Since you’re implementing app-managed photo storage, photoFilename is the correct semantic name.
Renaming the Field
Update the Task struct to use photoFilename:
public struct Task: Codable {
public let id: String
public let title: String
public let description: String
public let priority: Priority
public var isCompleted: Bool
public let photoFilename: String?
public init(
id: String,
title: String,
description: String,
priority: Priority,
isCompleted: Bool = false,
photoFilename: String? = nil
) {
self.id = id
self.title = title
self.description = description
self.priority = priority
self.isCompleted = isCompleted
self.photoFilename = photoFilename
}
}
Your Task struct now uses semantically correct naming for photo storage, and you’re ready to implement PhotoStorage.
Implementing PhotoStorage
Tasks can have associated photos. Now that your Task model uses photoFilename, you need a PhotoStorage class to handle photo file operations.
Why Photo Management Matters
Consider the lifecycle of task photos:
- User creates task with photo: App copies photo to app’s documents directory.
- User updates task with new photo: Old photo should be deleted, new photo saved.
- User deletes task: Photo file should be removed to free storage space.
Without proper photo management, orphaned files accumulate. After months of use, hundreds of deleted task photos could waste megabytes of storage. PhotoStorage provides centralized photo lifecycle management.
Understanding the Photos Directory
Photos are stored in a subdirectory of the app’s documents directory:
/data/data/com.kodeco.android.swiftsdkforandroid.taskmanager/
└── files/ (documents directory)
├── tasks.json
└── photos/ (photo storage)
├── 123.jpg
└── 456.jpg
Each photo is named with the task ID and .jpg extension. This naming scheme ensures:
- Uniqueness: Each task ID is a UUID, preventing filename collisions.
- Easy lookup: Given a task ID, you know the photo filename.
- Simple cleanup: When deleting a task, you know which file to remove.
Creating PhotoStorage.swift
Create a new file taskmanager-lib/Sources/TaskManagerKit/PhotoStorage.swift:
import Foundation
// 1
enum PhotoStorageError: Error {
case invalidPath
case saveFailed(Error)
case copyFailed(Error)
case deleteFailed(Error)
}
class PhotoStorage {
// 2
private static func photosDirectory() -> URL? {
guard let documentsDir = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
).first else {
return nil
}
let photosDir = documentsDir.appendingPathComponent("photos")
if !FileManager.default.fileExists(atPath: photosDir.path) {
try? FileManager.default.createDirectory(
at: photosDir,
withIntermediateDirectories: true
)
}
return photosDir
}
// 3
public static func savePhoto(
data: Data,
withFilename filename: String,
documentsPath: String
) -> Result<String, PhotoStorageError> {
let photosPath = (documentsPath as NSString).appendingPathComponent("photos")
let photosURL = URL(fileURLWithPath: photosPath)
do {
try FileManager.default.createDirectory(
at: photosURL,
withIntermediateDirectories: true,
attributes: nil
)
} catch {
return .failure(.saveFailed(error))
}
let destinationURL = photosURL.appendingPathComponent(filename)
do {
try data.write(to: destinationURL)
return .success(filename)
} catch {
return .failure(.saveFailed(error))
}
}
// 4
public static func savePhoto(
fromPath sourcePath: String,
withFilename filename: String
) -> Result<String, PhotoStorageError> {
guard let photosDir = photosDirectory() else {
return .failure(.invalidPath)
}
let sourceURL = URL(fileURLWithPath: sourcePath)
let destURL = photosDir.appendingPathComponent(filename)
do {
if FileManager.default.fileExists(atPath: destURL.path) {
try FileManager.default.removeItem(at: destURL)
}
try FileManager.default.copyItem(at: sourceURL, to: destURL)
return .success(filename)
} catch {
return .failure(.copyFailed(error))
}
}
// 5
public static func photoPath(for filename: String, documentsPath: String) -> String? {
let photosDir = documentsPath + "/photos"
let photoPath = photosDir + "/" + filename
return photoPath
}
// 6
public static func deletePhoto(filename: String) -> Result<Void, PhotoStorageError> {
guard let photosDir = photosDirectory() else {
return .failure(.invalidPath)
}
let photoURL = photosDir.appendingPathComponent(filename)
do {
if FileManager.default.fileExists(atPath: photoURL.path) {
try FileManager.default.removeItem(at: photoURL)
}
return .success(())
} catch {
return .failure(.deleteFailed(error))
}
}
// 7
public static func photoExists(filename: String) -> Bool {
guard let photosDir = photosDirectory() else {
return false
}
let photoURL = photosDir.appendingPathComponent(filename)
return FileManager.default.fileExists(atPath: photoURL.path)
}
}
The above of:
-
Has a
PhotoStorageErrorenum that defines specific error cases for photo operations. LikeStorageErrorfor tasks, this provides detailed error information that callers can handle appropriately. -
Creates a
photosDirectory()that gets the app’s documents directory, appends “photos” as a subdirectory path, creates it if it doesn’t exist, and returns the full photos directory URL. -
Uses a
savePhoto(data:withFilename:documentsPath:)to save photo data to a file. This overload accepts:
-
data: Raw image data (Data type) -
filename: What to name the file (e.g., “123.jpg”) -
documentsPath: Absolute path to documents directory (provided by Android)
Note: Why the
documentsPathparameter? On iOS,FileManager.default.urls(for: .documentDirectory)works reliably. On Android via Swift SDK, you must pass the path from Kotlin (context.filesDir.absolutePath) because FileManager doesn’t always resolve the correct Android path.
-
Makes an alternate overload function
savePhoto(fromPath:withFilename:)that copies a file from one path to another. This is useful when you already have a photo file (like from camera picker) and want to copy it to the photos directory. -
Creates a
photoPath(for:documentsPath:)that constructs the full path to a photo file. Given a filename and documents path, it returns the complete path string that Kotlin can use to load the image. -
Implements a
deletePhoto(filename:)to remove a photo file. -
Creates a
photoExists(filename:)to simply check if a photo file exists.
Why Static Methods?
Notice all public methods are static. This design choice serves multiple purposes:
- No state: PhotoStorage doesn’t maintain state between calls. Each operation is independent.
- No lifecycle: You don’t need to create, retain, or destroy PhotoStorage instances.
-
Clear intent:
PhotoStorage.deletePhoto()clearly indicates a class-level operation. - swift-java compatibility: Static methods map naturally to Kotlin object functions.
This pattern matches TaskStorage, which also uses instance methods but could reasonably be static. The difference is that TaskStorage is instantiated by TaskManager as storage, while PhotoStorage is called directly as PhotoStorage.deletePhoto().
Save the file. PhotoStorage is now ready to manage task photos throughout their lifecycle.
Implementing TaskManager.updateTask()
With TaskStorage.saveTasks() in place, you can now implement the Update operation. When a user edits a task, you need to find the task, validate the new data, update it in memory, and persist to disk.
Why Update Needs Validation
In Segment 02, you focused on loading tasks. The data was already validated when it was created. But for updates, you must validate again because:
- Users make mistakes: Empty title, description too short, etc.
- UI bugs: Kotlin code might pass invalid data.
- Defense in depth: Validate at system boundaries (Swift layer is the boundary for persistence).
Validation in updateTask() ensures that no invalid data ever reaches the disk, even if Kotlin validation is bypassed.
Implementing the Method
Open taskmanager-lib/Sources/TaskManagerKit/TaskManager.swift. Add this method after addTask():
@discardableResult
public func updateTask(_ updatedTask: Task) -> Bool {
// 1
guard let index = tasks.firstIndex(where: { $0.id == updatedTask.id }) else {
return false
}
// 2
guard TaskValidator.validateTitle(updatedTask.title),
TaskValidator.validateDescription(updatedTask.description) else {
return false
}
// 3
let oldTask = tasks[index]
if let oldPhoto = oldTask.photoFilename,
oldPhoto != updatedTask.photoFilename {
_ = PhotoStorage.deletePhoto(filename: oldPhoto)
}
// 4
tasks[index] = updatedTask
// 5
_ = storage.saveTasks(tasks)
// 6
return true
}
What each section does:
-
Find task:
tasks.firstIndex(where:)searches for a task with matchingid. If not found, returnsfalse. The method signature accepts a completeTaskobject instead of individual parameters, which simplifies the API and ensures all task properties stay consistent. -
Validate: Uses
TaskValidator.validateTitle()andTaskValidator.validateDescription()instead of inline validation. This keeps TaskManager focused on orchestration while validation logic stays centralized. If either validation fails, returnsfalseimmediately. -
Photo cleanup: Before updating, checks if the old task had a photo that’s different from the new one. If so, calls
PhotoStorage.deletePhoto()to remove the old file. The_discards the Result because even if deletion fails (file already gone), the update should proceed. This prevents orphaned photo files when users change or remove photos. -
Update in place: Replaces the task at the found index with
updatedTask. Because Task is a struct (value type), this creates a copy with new values while preserving the array position. -
Immediate persistence: Calls
_ = storage.saveTasks(tasks)to write changes to disk. The_discards the Result. Even if save fails (rare), the in-memory array is updated and the operation returnstrue. In a production app, you might want to returnfalseif save fails, but for this learning project, optimistic success keeps the API simple. -
Return success: Returns
trueto indicate the update completed.
Understanding @discardableResult
The @discardableResult attribute allows callers to ignore the return value without compiler warnings:
// Both are valid:
let success = manager.updateTask(task) // Use the result
manager.updateTask(task) // Ignore the result
Without this attribute, ignoring the Bool would trigger a warning. The attribute is appropriate here because sometimes you want to check success, sometimes you just want to fire-and-forget (trusting validation happened elsewhere).
Save the file and rebuild:
./gradlew taskmanager-lib:clean
cd taskmanager-lib
swift build
You should see a clean build. The Update operation is complete. Now on to deleting a task.
Implementing TaskManager.deleteTask()
Deleting a task is simpler than updating, but it introduces a new concept: cascading deletes. When you delete a task, you must also delete the associated photo file (if it exists).
Implementing the Method
Add this method to TaskManager.swift after updateTask():
@discardableResult
public func deleteTask(id: String) -> Bool {
// 1
guard let index = tasks.firstIndex(where: { $0.id == id }) else {
return false
}
// 2
let task = tasks[index]
// 3
if let photoFilename = task.photoFilename {
_ = PhotoStorage.deletePhoto(filename: photoFilename)
}
// 4
tasks.remove(at: index)
// 5
_ = storage.saveTasks(tasks)
// 6
return true
}
The code breakdown:
-
Find task:
tasks.firstIndex(where:)searches for the task by ID. If not found, returnsfalse. This makes the operation idempotent—callingdeleteTask()on a non-existent ID is safe and simply returnsfalse. -
Get task reference: Captures the task in a variable before removal. You need this reference to check for an associated photo filename.
-
Photo cleanup: If
task.photoFilenameexists, callsPhotoStorage.deletePhoto()to remove the photo file. The_discards the Result because the delete should succeed even if the photo file is missing (maybe it was manually deleted or never existed). This is cascading delete in action—removing the parent task triggers child resource cleanup. -
Remove from array:
tasks.remove(at: index)removes the task at the found index and shifts remaining elements down. This is an O(n) operation for arrays, but with dozens of tasks, performance impact is negligible. -
Immediate persistence: Calls
_ = storage.saveTasks(tasks)to write the updated array to disk, same pattern asupdateTask(). -
Return success: Returns
trueto indicate successful deletion. LikeupdateTask(), this Boolean return is simpler than Result types and integrates cleanly with swift-java’s Kotlin bindings.
Understanding Cascading Deletes
The term cascading delete comes from database design. In SQL databases with foreign keys, you can configure ON DELETE CASCADE to automatically delete child records when you delete a parent record.
With file-based storage, you don’t have foreign keys, so you implement cascading manually:
Delete Task (parent)
↓
Check if task.photoFilename exists
↓
If yes, delete photo file (child)
↓
Remove task from array
↓
Persist to disk
This ensures referential integrity. No orphaned photo files remain after deletion. Over time, this cleanup prevents wasted storage space from accumulating.
Understanding Idempotent Operations
Notice that deleting a non-existent task returns false, but deleting a photo that doesn’t exist is silently ignored (returns success). Both are examples of idempotent operations—you can call them multiple times with the same result.
Idempotent deletes are safer:
- Network retries: If Kotlin retries a delete after network hiccup, it won’t fail.
- Cleanup code: You can safely call delete in finally blocks without checking existence.
- User experience: Double-tapping delete won’t show confusing errors.
The difference in return values reflects intent: deleteTask() returns false so Kotlin can show “task not found” UI feedback, while PhotoStorage.deletePhoto() treats missing files as success because photo cleanup is internal, not user-facing.
Implementing getTask(by:) for Individual Access
You’ve implemented Create (addTask), Update (updateTask), and Delete (deleteTask). To complete the CRUD pattern, you need the Read operation for individual tasks. While getTasks() returns all tasks, you often need to fetch a single task by ID.
Replace the related TODO in TaskManager.swift with:
public func getTask(by id: String) -> Task? {
return tasks.first(where: { $0.id == id })
}
This is the Read operation for a single record. Combined with getTasks() (read all) and getAllTasksJSON() (read as JSON), you now have complete read functionality.
Completing CRUD
You’ve now implemented all four CRUD operations:
-
Create:
addTask(_:)→ Append to array, validate, persist -
Read:
getTasks()for all,getTask(by:)for one,getAllTasksJSON()for serialization -
Update:
updateTask(_:)→ Find, validate, replace, persist -
Delete:
deleteTask(id:)→ Find, cleanup photos, remove, persist
This is a complete data access layer. All mutations persist immediately, all reads come from the source of truth, and error cases are handled explicitly.
Build checkpoint: Save the file and rebuild:
cd taskmanager-lib
swift build
You should see a clean build. Your CRUD implementation is complete on the Swift side. Next, you’ll learn how swift-java automatically exposes these methods to Kotlin.
Exposing Methods to Kotlin with swift-java
Your Swift code is complete and ready to use.
How swift-java Works
The swift-java plugin is a build tool that scans your Swift code and generates JNI bindings automatically. When you build the Android project:
- Swift code is compiled: Your TaskManager, TaskStorage, and other Swift classes are built into a native library
- swift-java scans: The plugin analyzes your Swift API surface (public methods, classes, structs)
- JNI wrappers generated: For each public method, swift-java creates C-style JNI export functions
- Kotlin bindings created: Extension functions and classes are generated that Kotlin code can call naturally
- Type marshaling handled: Complex types like Task objects are automatically serialized/deserialized across the boundary
This automation eliminates hundreds of lines of manual JNI boilerplate you’d otherwise write.
The public static Pattern
For swift-java to expose your methods to Kotlin, they must follow a specific pattern. Looking at your TaskManager methods:
@discardableResult
public func updateTask(_ updatedTask: Task) -> Bool { ... }
@discardableResult
public func deleteTask(id: String) -> Bool { ... }
These are instance methods on the TaskManager class. To call them from Kotlin, you need access to the instance. That’s where the accessor comes in.
Adding the getShared() Accessor
swift-java cannot directly expose Swift’s static let shared property because static properties don’t map cleanly to JNI. Instead, you provide a static accessor method.
Open TaskManager.swift and verify this method exists (it should be there from Lesson 2):
public static func getShared() -> TaskManager {
return shared
}
This method:
- Is
public static, so swift-java can expose it as a class method - Returns the singleton
TaskManagerinstance - Allows Kotlin to call
TaskManager.getShared(arena)to get the instance
From Kotlin, the pattern looks like:
val arena = SwiftArena.ofAuto()
val manager = TaskManager.getShared(arena)
manager.updateTask(task) // Calls Swift TaskManager.updateTask()
The arena parameter is how swift-java manages memory for Swift objects in the JVM. You don’t need to understand the details—just know that swift-java handles it automatically.
Understanding Object Marshaling
When Kotlin calls manager.updateTask(task), how does the Kotlin Task object become a Swift Task object? swift-java generates marshaling code:
Kotlin Task → JSON serialization → JNI boundary → JSON deserialization → Swift Task
The Task object is converted to a format that crosses the JNI boundary, then reconstructed on the Swift side.
This is why you defined Task as @Serializable in Kotlin and Codable in Swift. swift-java uses these protocols to automatically generate the marshaling code.
Your Swift code is already properly structured for swift-java. Clean and re-build the taskmanager-lib module with:
./gradlew taskmanager-lib:clean
cd taskmanager-lib
swift build
The generated bindings are in .build/plugins/outputs/swift-java/. You won’t edit these files directly, but you can inspect them to see the JNI glue code that swift-java created.
The app itself won’t fully build yet. In the next section you’ll utilize the compiled swift-java code and integrate it with Kotlin.
Key Takeaways
You’ve completed the Swift CRUD layer with validation, photo management, and automatic Kotlin bindings. Here’s what you learned:
- The CRUD pattern provides standard vocabulary for data operations: Create, Read, Update, Delete. This consistency makes codebases easier to understand and maintain.
- TaskValidator centralizes validation logic with static methods for title and description checks. Separating validation from business logic makes both more testable and maintainable.
- PhotoStorage manages photo lifecycle with static methods for save, delete, and path operations. Centralized photo management prevents orphaned files and keeps storage clean.
-
JSONEncoder enables serialization with
.prettyPrintedformatting for debugging. In production, you’d remove pretty printing to reduce file size, but during development, readability matters more than optimization. - Atomic writes prevent file corruption by writing to a temporary file first, then renaming. This ensures you never end up with partially-written JSON that can’t be parsed.
- Boolean returns simplify error handling compared to Result types. When you need success/failure without detailed error information, Bool is cleaner and integrates better with swift-java.
- swift-java automates JNI bridging by scanning public methods and generating bindings automatically. This eliminates manual @_cdecl exports, type conversions, and memory management boilerplate.
- Object-based APIs reduce parameter count and improve type safety. Passing a Task object is cleaner than passing six individual parameters, and swift-java handles the marshaling automatically.
- Cascading deletes prevent orphaned resources by cleaning up related files (like photos) when you delete parent records. This maintains referential integrity in file-based storage.
In the next segment, you’ll integrate photo selection from the Android file picker and learn how to pass photo data from Kotlin through swift-java to Swift PhotoStorage for seamless cross-platform photo management.