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() and deleteTask() 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:

  1. Compose UI triggers TaskRepository.updateTask()
  2. Repository calls JNI export updateTaskJNI()
  3. JNI export converts types and calls TaskManager.updateTask()
  4. TaskManager validates data and updates in-memory array
  5. TaskManager calls TaskStorage.saveTasks()
  6. TaskStorage writes JSON to disk
  7. Repository calls loadTasks() to refresh StateFlow
  8. 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:

  1. Create encoder: JSONEncoder() is Swift’s built-in encoder for converting objects to JSON data. It’s the complement to JSONDecoder you used in loadTasks().

  2. Pretty printing: encoder.outputFormatting = .prettyPrinted formats the JSON with indentation and newlines, making it human-readable. During development, this makes debugging easier. You can open tasks.json in a text editor and inspect its contents.

  3. Encode tasks: encoder.encode(tasks) converts the [Task] array to Data (raw bytes). Because Task conforms to Codable, the encoder automatically serializes all properties.

  4. Atomic write: data.write(to:options:) writes the bytes to disk. The .atomic option tells FileManager to write to a temporary file first, then rename it to the final name. This prevents corruption if the app crashes mid-write.

  5. Success return: return .success(()) indicates successful write. The () is Swift’s empty tuple (Void). There’s no data to return, just success/failure status.

  6. 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:

  1. Start writing to tasks.json.
  2. App crashes halfway through.
  3. File contains partial, corrupted JSON.
  4. Next launch: loadTasks() fails with .corruptedData.

With .atomic:

  1. Write to temporary file tasks.json.tmp.
  2. App crashes. Temporary file is corrupted, but original tasks.json is intact.
  3. On successful write, rename tasks.json.tmp → tasks.json (atomic operation at OS level).
  4. 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:

  1. Constants: minTitleLength, maxTitleLength, etc. define validation rules as static constants. Using constants instead of magic numbers makes rules easy to understand and modify.

  2. 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.

  3. validateDescription(): Same pattern as title validation, but requires 10-200 characters. The higher minimum encourages meaningful descriptions instead of placeholders like “todo”.

  4. validatePriority(): Validates that a string can be converted to the Priority enum. The Priority(rawValue:) initializer returns nil for invalid strings, so checking != nil confirms 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:

  1. User creates task with photo: App copies photo to app’s documents directory.
  2. User updates task with new photo: Old photo should be deleted, new photo saved.
  3. 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:

  1. Has a PhotoStorageError enum that defines specific error cases for photo operations. Like StorageError for tasks, this provides detailed error information that callers can handle appropriately.

  2. 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.

  3. 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 documentsPath parameter? 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.

  1. 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.

  2. 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.

  3. Implements a deletePhoto(filename:) to remove a photo file.

  4. 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:

  1. No state: PhotoStorage doesn’t maintain state between calls. Each operation is independent.
  2. No lifecycle: You don’t need to create, retain, or destroy PhotoStorage instances.
  3. Clear intent: PhotoStorage.deletePhoto() clearly indicates a class-level operation.
  4. 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:

  1. Users make mistakes: Empty title, description too short, etc.
  2. UI bugs: Kotlin code might pass invalid data.
  3. 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:

  1. Find task: tasks.firstIndex(where:) searches for a task with matching id. If not found, returns false. The method signature accepts a complete Task object instead of individual parameters, which simplifies the API and ensures all task properties stay consistent.

  2. Validate: Uses TaskValidator.validateTitle() and TaskValidator.validateDescription() instead of inline validation. This keeps TaskManager focused on orchestration while validation logic stays centralized. If either validation fails, returns false immediately.

  3. 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.

  4. 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.

  5. 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 returns true. In a production app, you might want to return false if save fails, but for this learning project, optimistic success keeps the API simple.

  6. Return success: Returns true to 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:

  1. Find task: tasks.firstIndex(where:) searches for the task by ID. If not found, returns false. This makes the operation idempotent—calling deleteTask() on a non-existent ID is safe and simply returns false.

  2. Get task reference: Captures the task in a variable before removal. You need this reference to check for an associated photo filename.

  3. Photo cleanup: If task.photoFilename exists, calls PhotoStorage.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.

  4. 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.

  5. Immediate persistence: Calls _ = storage.saveTasks(tasks) to write the updated array to disk, same pattern as updateTask().

  6. Return success: Returns true to indicate successful deletion. Like updateTask(), 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:

  1. Swift code is compiled: Your TaskManager, TaskStorage, and other Swift classes are built into a native library
  2. swift-java scans: The plugin analyzes your Swift API surface (public methods, classes, structs)
  3. JNI wrappers generated: For each public method, swift-java creates C-style JNI export functions
  4. Kotlin bindings created: Extension functions and classes are generated that Kotlin code can call naturally
  5. 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 TaskManager instance
  • 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 .prettyPrinted formatting 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.

See forum comments
Download course materials from Github
Previous: Persistent Load Workflow Next: Photo Storage & Kotlin Integration