Photo Storage & Kotlin Integration

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

In the last segment, you built a complete Swift persistence layer: TaskManager with CRUD operations, PhotoStorage for managing photo files, and TaskStorage for JSON serialization. Your Swift code is production-ready and thoroughly tested.

But there’s a critical gap: your Kotlin UI can’t use these Swift methods yet. The Swift methods are compiled and exposed via swift-java, but you haven’t wired them up to your Kotlin Repository layer. The UI still displays mock data, and edits don’t persist.

In this segment, you’ll complete the integration:

  • Wire TaskRepository methods to Swift CRUD operations via swift-java.
  • Implement photo display in TaskCard using cross-platform path resolution.
  • Add getPhotoPath() bridge method to TaskManager.
  • Build edit functionality with pre-populated forms.
  • Test the complete workflow from UI tap to disk persistence.

By the end, your app will have a fully functional CRUD system where UI changes flow through Swift and persist to disk, complete with photo support and reactive updates via StateFlow.

Understanding the Integration Architecture

Before wiring up Kotlin to Swift, let’s understand how the layers fit together and why this architecture provides clean separation of concerns.

The Complete Stack

When a user taps “Edit” on a task card, data flows through five layers:

1. Compose UI (TaskCard)
   ↓
2. TaskRepository (Kotlin single source of truth)
   ↓
3. swift-java bindings (automatic type marshaling)
   ↓
4. TaskManager (Swift business logic)
   ↓
5. TaskStorage (Swift file I/O)

Building the Photo Save Helper

To begin, you need a helper method that bridges Android’s photo system with Swift’s photo storage. When users add or change task photos, the photo data comes from Android (camera, gallery, file picker) but needs to be saved via Swift’s PhotoStorage.

Implementing savePhotoViaPath()

Open TaskRepository.kt and add this private helper method after the init block and before other methods:

private fun savePhotoViaPath(taskId: String, photoUri: String, context: Context): String? {
  try {
    // 1
    val sourceFile = when {
      photoUri.startsWith("file://") -> File(photoUri.removePrefix("file://"))
      photoUri.startsWith("/") -> File(photoUri)
      else -> {
        println("Invalid photo URI format: $photoUri")
        return null
      }
    }
    
    // 2
    if (!sourceFile.exists()) {
      println("Source photo file doesn't exist: ${sourceFile.absolutePath}")
      return null
    }
    
    // 3
    val tempFile = File(context.cacheDir, "temp_photo_$taskId.jpg")
    try {
      // 4
      sourceFile.copyTo(tempFile, overwrite = true)
      
      // 5
      val documentsPath = context.filesDir.absolutePath
      
      // 6
      val savedPath = TaskManager.savePhotoFromPath(taskId, tempFile.absolutePath, documentsPath)
      
      // 7
      return savedPath.orElse(null)
    } finally {
      // 8
      if (tempFile.exists()) {
        tempFile.delete()
      }
    }
  } catch (e: Exception) {
    // 9
    e.printStackTrace()
    println("Failed to save photo: ${e.message}")
    return null
  }
}

Verifying Swift’s savePhotoFromPath Method

The helper calls TaskManager.savePhotoFromPath(), which doesn’t exist yet. You’ll add that now.

public static func savePhotoFromPath(taskId: String, sourcePath: String, documentsPath: String) -> String? {
  let fileURL = URL(fileURLWithPath: sourcePath)
  
  guard let photoData = try? Data(contentsOf: fileURL) else {
    print("Failed to read photo from path: \(sourcePath)")
    return nil
  }
  
  let filename = "\(taskId).jpg"
  
  switch PhotoStorage.savePhoto(data: photoData, withFilename: filename, documentsPath: documentsPath) {
  case .success(let path):
    return filename
  case .failure(let error):
    print("Failed to save photo: \(error)")
    return nil
  }
}

Implementing TaskRepository.updateTask()

With the architecture clear and PhotoStorage understood, you’re ready to wire up Kotlin’s Repository layer to Swift’s CRUD operations. Start with the update operation, which is the most complex due to photo handling.

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)
  
  init {
    loadTasks()
  }
  
  // ... addTask() exists here ...
  
  // TODO: Implement updateTask() to support editing tasks
}

Implementing updateTask()

Add this method after addTask():

fun updateTask(
  id: String,
  title: String,
  description: String,
  priority: Priority,
  photoUri: String? = null,
  context: Context? = null
): Result<Unit> {
  // 1
  val existingTask = manager.getTask(id, arena).orElse(null) 
    ?: return Result.failure(Exception("Task not found"))
  
  // 2
  val photoFilename = if (photoUri != null && photoUri.isNotEmpty() && context != null) {
    savePhotoViaPath(id, photoUri, context)
  } else {
    existingTask.getPhotoFilename().orElse(null)
  }
  
  // 3
  val task = Task.init(
    id,
    title,
    description,
    priority,
    existingTask.isCompleted(),
    Optional.ofNullable(photoFilename),
    arena
  )
  
  // 4
  val success = manager.updateTask(task)
  
  if (success) {
    // 5  
    loadTasks() 
    
    // 6
    return Result.success(Unit)
  } else {
    return Result.failure(Exception("Update failed"))
  }
}

Understanding swift-java Object Marshaling

When you call manager.updateTask(task), swift-java:

Improving TaskRepository.addTask()

Now that you’ve implemented the updateTask() operation, you’ll improve addTask() for creating new tasks. It follows the same photo-handling pattern you just learned but is simpler because you’re creating the task from scratch, there’s no existing fields to preserve.

Updating the Method

Replace the TaskRepository.addTask() implementation with the following:

fun addTask(
  title: String,
  description: String,
  priority: Priority,
  photoUri: String? = null,
  context: Context? = null
): Result<Unit> {
  // 1
  if (!TaskValidator.validateTitle(title)) {
    return Result.failure(Exception("Title must be between 3 and 50 characters"))
  }

  // 2
  if (!TaskValidator.validateDescription(description)) {
    return Result.failure(Exception("Description must be between 10 and 200 characters"))
  }

  // 3
  val taskId = UUID.randomUUID().toString()

  // 4
  val photoFilename = if (photoUri != null && photoUri.isNotEmpty() && context != null) {
    savePhotoViaPath(taskId, photoUri, context)
  } else {
    null
  }
  
  // 5
  val task = Task.init(
    taskId,
    title,
    description,
    priority,
    false,  // New tasks start uncompleted
    Optional.ofNullable(photoFilename),
    arena
  )
  
  // 6
  val success = manager.addTask(task)

  // 7
  if (success) {
    loadTasks()
    return Result.success(Unit)
  } else {
    return Result.failure(Exception("Validation failed"))
  }
}

Implementing TaskRepository.deleteTask()

Deleting a task is fairly straight-forward because you don’t need to construct a Task object, you just pass the ID. However, you still follow the same pattern: call Swift, check result, reload on success.

Adding the Method

Add this method to TaskRepository.kt after updateTask():

fun deleteTask(taskId: String): Result<Unit> {
  val success = manager.deleteTask(taskId)
  
  if (success) {
    loadTasks()
    return Result.success(Unit)
  } else {
    return Result.failure(Exception("Failed to delete task"))
  }
}

Understanding Cascading Delete

The beauty of this implementation i that you don’t manage photo cleanup in Kotlin. It happens automatically in Swift:

// In TaskManager.deleteTask()
if let photoFilename = task.photoFilename {
  _ = PhotoStorage.deletePhoto(filename: photoFilename)
}
tasks.remove(at: index)
_ = storage.saveTasks(tasks)
taskRepository.deleteTask("abc-123")
// Task deleted AND photo deleted (if task had one)
// No additional code needed

Adding getPhotoPath() to TaskManager

Next, TaskCard needs to display task photos, but it only knows task IDs, not photo filenames or paths. You need a bridge method in TaskManager that resolves the following: task ID → photo filename → full file path.

Implementing getPhotoPath()

Open taskmanager-lib/Sources/TaskManagerKit/TaskManager.swift. Add this method after getTask(by:):

public func getPhotoPath(for taskId: String, documentsPath: String) -> String? {
  guard let task = tasks.first(where: { $0.id == taskId }),
        let filename = task.photoFilename else {
    return nil
  }
  
  return PhotoStorage.photoPath(for: filename, documentsPath: documentsPath)
}

Why documentsPath Parameter?

PhotoStorage needs documentsPath because Swift’s FileManager.default.urls(for: .documentDirectory) doesn’t work correctly on Android. You must pass the path from Kotlin. In TaskCard, you’ll get it from LocalContext:

// In TaskCard composable
val context = LocalContext.current
TaskManager.getShared(arena).getPhotoPath(task.id, context.filesDir.absolutePath)

Displaying Photos in TaskCard

With getPhotoPath() available in TaskManager, you can now display task photos in your Compose UI. The TaskCard composable will fetch photo paths and load them using AsyncImage.

Understanding TaskCard’s Current State

Open app/src/main/java/com/kodeco/android/swiftsdkforandroid/taskmanager/ui/TaskCard.kt. The Starter project has a basic TaskCard that displays task information but has a TODO for photos:

@Composable
fun TaskCard(
  task: Task,
  onEdit: (Task) -> Unit = {},
  onDelete: (Task) -> Unit = {}
) {
  Card(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
    Column(modifier = Modifier.padding(16.dp)) {
      Text(task.title, style = MaterialTheme.typography.titleMedium)
      Text(task.description, style = MaterialTheme.typography.bodySmall)
      
      // TODO: Display photo if task has one
      
      // Action buttons...
    }
  }
}

Implementing TaskCard with Photo Display

Replace the TaskCard implementation with this complete version:

@Composable
fun TaskCard(
  task: Task,
  onEdit: (Task) -> Unit = {},
  onDelete: (Task) -> Unit = {}
) {
  // 1
  val arena = remember { SwiftArena.ofConfined() }
  val context = LocalContext.current
  
  // 2
  val photoUri = task.getPhotoFilename().orElse(null)?.let { filename ->
    TaskManager.getShared(arena).getPhotoPath(task.id, context.filesDir.absolutePath)?.get().let { path ->
      "file://$path"
    }
  }

  Card(
    modifier = Modifier
      .fillMaxWidth()
      .padding(horizontal = 16.dp, vertical = 8.dp),
    elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
  ) {
    Column(
      modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
    ) {
      // 3
      Text(
        text = task.title,
        style = MaterialTheme.typography.titleMedium,
        fontWeight = FontWeight.Bold
      )
      
      Spacer(modifier = Modifier.height(8.dp))
      
      Text(
        text = task.description,
        style = MaterialTheme.typography.bodyMedium,
        color = MaterialTheme.colorScheme.onSurfaceVariant
      )
      
      // 4
      if (photoUri != null) {
        Spacer(modifier = Modifier.height(12.dp))
        
        AsyncImage(
          model = photoUri,
          contentDescription = "Task photo",
          modifier = Modifier
            .fillMaxWidth()
            .height(200.dp),
          contentScale = ContentScale.Crop
        )
      }
      
      Spacer(modifier = Modifier.height(12.dp))
      
      // 5
      Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
      ) {
        PriorityBadge(priority = task.getPriority(arena).rawValue)
        
        // 6
        Row {
          IconButton(onClick = { onEdit(task) }) {
            Icon(
              imageVector = Icons.Default.Edit,
              contentDescription = "Edit task"
            )
          }
          
          IconButton(onClick = { onDelete(task) }) {
            Icon(
              imageVector = Icons.Default.Delete,
              contentDescription = "Delete task",
              tint = MaterialTheme.colorScheme.error
            )
          }
        }
      }
    }
  }
}
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.kodeco.android.taskmanagerkit.Task
import com.kodeco.android.taskmanagerkit.TaskManager
import org.swift.swiftkit.core.SwiftArena

Understanding the Implementation

Let’s break down what each numbered section does:

Adding Edit Functionality

With photos displaying, the last piece is edit functionality. Users should tap a task card’s edit button, see a dialog pre-populated with current values, make changes, and save.

Updating CreateTaskDialog for Edit Mode

The Starter project’s CreateTaskDialog has this signature:

@Composable
fun CreateTaskDialog(
  onDismiss: () -> Unit
) {
  // ... only creates new tasks
}

Adding editTask Parameter

Open app/src/main/java/.../ui/CreateTaskDialog.kt and modify the function signature:

@Composable
fun CreateTaskDialog(
  onDismiss: () -> Unit,
  editTask: Task? = null  // NEW: null = create mode, Task = edit mode
) {

Adding Mode Detection

Add this right after the function signature, before any state declarations:

val arena = remember { SwiftArena.ofConfined() }
val isEditMode = editTask != null

Pre-Populating State from editTask

Find your existing state declarations and update them to use editTask values:

var title by remember { mutableStateOf(editTask?.title ?: "") }
var description by remember { mutableStateOf(editTask?.description ?: "") }
var priority by remember {
  mutableStateOf(
    editTask?.let { it.getPriority(arena) } ?: Priority.medium(arena)
  )
}

Adding Photo Loading Effect

After your state declarations, add this LaunchedEffect:

// Load existing photo when editing
LaunchedEffect(editTask) {
  // Note: photoUri not in Swift Task model - managed separately
}

Updating Dialog Title

Find the AlertDialog title parameter and update it:

AlertDialog(
  onDismissRequest = onDismiss,
  title = {
    Text(text = if (isEditMode) "Edit Task" else stringResource(R.string.add_task))
  },
  // ... rest of dialog
)

Implementing Branching Save Logic

This is the critical piece. Find your save Button’s onClick handler and replace it with:

Button(
  onClick = {
    // 1
    val finalUri = if (displayedBitmap != null && displayedBitmap != originalBitmap) {
      saveBitmapAndGetUri(context, displayedBitmap!!)
    } else {
      photoUri
    }

    // 2
    val result = if (isEditMode) {
      TaskRepository.updateTask(
        id = editTask!!.id,
        title = title,
        description = description,
        priority = priority,
        photoUri = finalUri?.toString(),
        context = context
      )
    } else {
      TaskRepository.addTask(
        title = title,
        description = description,
        priority = priority,
        photoUri = finalUri?.toString(),
        context = context
      )
    }
    
    // 3
    result.onSuccess {
      onDismiss()
    }.onFailure { error ->
      errorMessage = error.message ?: "Validation failed"
    }
  }
) {
  Text(stringResource(R.string.save))
}

Wiring Edit and Delete in TaskListScreen

TaskCard’s action buttons are already implemented. Now wire them in your TaskListScreen to handle edit and delete operations. Open ui/TaskListScreen.kt and update the LazyColumn section:

@Composable
fun TaskListScreen() {
  val tasks by TaskRepository.tasks.collectAsState()
  var showCreateDialog by remember { mutableStateOf(false) }
  // 1
  var taskToEdit by remember { mutableStateOf<Task?>(null) }
  
  Scaffold(
    topBar = { /* Use existing TopAppBar with app name */ },
    floatingActionButton = { /* Use existing FAB for creating tasks */ }
  ) { paddingValues ->
    Box(
      modifier = Modifier
        .fillMaxSize()
        .padding(paddingValues)
    ) {
      if (tasks.isEmpty()) {
        Text("No tasks yet")
      } else {
        LazyColumn(modifier = Modifier.fillMaxSize()) {
          items(tasks) { task ->
            // 2
            TaskCard(
              task = task,
              onEdit = { taskToEdit = it },
              onDelete = { TaskRepository.deleteTask(it.id) }
            )
          }
        }
      }
    }
    
    // 3
    if (showCreateDialog) {
      CreateTaskDialog(
        onDismiss = { showCreateDialog = false }
      )
    }
    
    // 4
    taskToEdit?.let { task ->
      CreateTaskDialog(
        onDismiss = { taskToEdit = null },
        editTask = task
      )
    }
  }
}
cd taskmanager-lib
swift build

cd ..
./gradlew :app:assembleDebug

Testing the Edit Functionality

It’s time for you to test and validate the full update workflow from UI to persistence and back.

Full edit functionality
Cihq ekid josfkeafepihy

Learning Resources

To deepen your understanding of the technologies used in this lesson:

Key Takeaways

You’ve completed the integration between Kotlin UI and Swift persistence layer, creating a fully functional CRUD system with photo support. Here’s what you learned:

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