UI Integration & Testing

In this segment, you’ll connect your camera and image processing components to the task creation UI. You’ll add buttons for capturing photos and applying filters, then test the complete flow from camera capture to filtered display.

The UI layer orchestrates the entire process, calling Android camera APIs and Swift image processing functions through the repositories you created.

Updating the Task Model for Photo Storage

Before adding photo capture to the UI, you need to update the Swift Task model to support storing photo URIs.

The Task struct needs a new property to hold the photo’s URI. This allows tasks to reference their associated photos, which are stored as files in the app’s directory.

Open taskmanager-lib/Sources/TaskManagerKit/Task.swift and update the Task struct:

public struct Task: Codable {
  public let id: String
  public let title: String
  public let description: String
  public let priority: Priority
  public var isCompleted: Bool
  // 1
  public let photoUri: String?

  // 2
  public init(
    id: String,
    title: String,
    description: String,
    priority: Priority,
    isCompleted: Bool = false,
    photoUri: String? = nil
  ) {
    self.id = id
    self.title = title
    self.description = description
    self.priority = priority
    self.isCompleted = isCompleted
    // 3
    self.photoUri = photoUri
  }
}

What each section does:

  1. photoUri property: Optional String to store the file URI of the task’s photo. Optional because not all tasks will have photos.
  2. Updated initializer: Adds photoUri parameter with a default value of nil, maintaining backward compatibility with existing code.
  3. Property assignment: Assigns the photoUri parameter to the property during initialization.

This change is backward compatible because the photoUri parameter has a default value. Existing code that creates tasks without photos will continue to work, while new code can provide a photo URI when creating tasks with photos.

Updating the Task Repository

Now that the Task model supports photo URIs, you need to update the repository to accept and pass this data when creating tasks.

Open app/src/main/java/com/kodeco/android/swiftsdkforandroid/taskmanager/repository/TaskRepository.kt and update the addTask function.

First, add the missing import at the top of the file:

import java.util.Optional

Then update the addTask function to accept and handle the photoUri parameter:

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

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

  // 3
  val task = Task.init(
    UUID.randomUUID().toString(),
    title,
    description,
    priority,
    false,
    Optional.ofNullable(photoUri),
    arena
  )

  // 4
  _tasks.value += task
  return Result.success(Unit)
}

What each section does:

  1. Updated signature: Adds optional photoUri parameter with a default value of null, maintaining backward compatibility.
  2. Validation: Existing validation logic remains unchanged.
  3. Task creation: Passes Optional.ofNullable(photoUri) to the Swift Task initializer. This wraps the nullable Kotlin String in a Java Optional, which swift-java maps to Swift’s Optional type.
  4. Add to list: Adds the new task to the in-memory list.

The key change is passing Optional.ofNullable(photoUri) as the sixth parameter to Task.init(). Swift’s Optional type is represented as Java’s Optional in the JNI bindings, so you use Optional.ofNullable() to convert Kotlin’s nullable String to the expected type.

Updating the Create Task Dialog

Now you’ll integrate photo capture, preview, and filtering into the task creation dialog. This requires adding state management, camera integration, filter buttons, and helper functions.

The complete updated CreateTaskDialog.kt brings together all the components you’ve built: camera capture, image processing, and the UI for applying filters.

Replace the contents of app/src/main/java/com/kodeco/android/swiftsdkforandroid/taskmanager/ui/CreateTaskDialog.kt with this complete implementation:

package com.kodeco.android.swiftsdkforandroid.taskmanager.ui

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.kodeco.android.swiftsdkforandroid.taskmanager.R
import com.kodeco.android.taskmanagerkit.Priority
import org.swift.swiftkit.core.SwiftArena
import com.kodeco.android.swiftsdkforandroid.taskmanager.repository.ImageProcessingRepository
import com.kodeco.android.swiftsdkforandroid.taskmanager.repository.TaskRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateTaskDialog(
  onDismiss: () -> Unit
) {
  val arena = remember { SwiftArena.ofConfined() }

  var title by remember { mutableStateOf("") }
  var description by remember { mutableStateOf("") }
  var priority by remember { mutableStateOf(Priority.medium(arena)) }
  var expanded by remember { mutableStateOf(false) }
  var errorMessage by remember { mutableStateOf<String?>(null) }
  // 1
  var photoUri by remember { mutableStateOf<Uri?>(null) }
  var showCamera by remember { mutableStateOf(false) }
  // 2
  var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
  var displayedBitmap by remember { mutableStateOf<Bitmap?>(null) }
  var isProcessing by remember { mutableStateOf(false) }

  val coroutineScope = rememberCoroutineScope()
  val context = LocalContext.current


  val priorities = remember(arena) {
    listOf(
      Priority.low(arena),
      Priority.medium(arena),
      Priority.high(arena)
    )
  }

  // 3
  if (showCamera) {
    CameraPermissionHandler(
      onPermissionGranted = {
        CameraScreen(
          onPhotoCaptured = { uri ->
            photoUri = uri
            // 4
            originalBitmap = loadBitmapFromUri(context, uri)
            displayedBitmap = originalBitmap
            showCamera = false
          },
          onDismiss = {
            showCamera = false
          }
        )
      },
      onPermissionDenied = {
        showCamera = false
      }
    )
    return
  }


  AlertDialog(
    onDismissRequest = onDismiss,
    title = {
      Text(text = stringResource(R.string.add_task))
    },
    text = {
      Column(
        modifier = Modifier
          .fillMaxWidth()
          .padding(vertical = 8.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
      ) {
        OutlinedTextField(
          value = title,
          onValueChange = { title = it },
          label = { Text(stringResource(R.string.task_title)) },
          modifier = Modifier.fillMaxWidth(),
          singleLine = true
        )

        OutlinedTextField(
          value = description,
          onValueChange = { description = it },
          label = { Text(stringResource(R.string.task_description)) },
          modifier = Modifier.fillMaxWidth(),
          minLines = 3,
          maxLines = 5
        )

        ExposedDropdownMenuBox(
          expanded = expanded,
          onExpandedChange = { expanded = !expanded }
        ) {
          OutlinedTextField(
            value = priority.rawValue,
            onValueChange = {},
            readOnly = true,
            label = { Text(stringResource(R.string.task_priority)) },
            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
            modifier = Modifier
              .menuAnchor(MenuAnchorType.PrimaryNotEditable)
              .fillMaxWidth()
          )

          ExposedDropdownMenu(
            expanded = expanded,
            onDismissRequest = { expanded = false }
          ) {
            priorities.forEach { option ->
              DropdownMenuItem(
                text = { Text(option.rawValue) },
                onClick = {
                  priority = option
                  expanded = false
                }
              )
            }
          }
        }

        // 5
        Button(
          onClick = { showCamera = true },
          modifier = Modifier.fillMaxWidth()
        ) {
          Icon(
            imageVector = Icons.Default.CameraAlt,
            contentDescription = null,
            modifier = Modifier.padding(end = 8.dp)
          )
          Text("Add Photo")
        }

        // 6
        displayedBitmap?.let { bitmap ->
          AsyncImage(
            model = bitmap,
            contentDescription = "Task photo preview",
            modifier = Modifier
              .fillMaxWidth()
              .height(150.dp),
            contentScale = ContentScale.Crop
          )

          if (isProcessing) {
            Text(
              text = "Processing...",
              style = MaterialTheme.typography.bodySmall,
              modifier = Modifier.padding(vertical = 4.dp)
            )
          }

          // 7
          Row(
            modifier = Modifier
              .fillMaxWidth()
              .horizontalScroll(rememberScrollState()),
            horizontalArrangement = Arrangement.spacedBy(8.dp)
          ) {
            // 8
            Button(
              onClick = {
                displayedBitmap = originalBitmap
              }
            ) {
              Text("Original")
            }

            // 9
            Button(
              onClick = {
                originalBitmap?.let { original ->
                  isProcessing = true
                  coroutineScope.launch {
                    val result = withContext(Dispatchers.Default) {
                      ImageProcessingRepository.applyFilter(
                        original,
                        ImageProcessingRepository.FilterType.GRAYSCALE,
                        context
                      )
                    }
                    displayedBitmap = result
                    isProcessing = false
                  }
                }
              },
              enabled = !isProcessing
            ) {
              Text("Grayscale")
            }

            // 10
            Button(
              onClick = {
                originalBitmap?.let { original ->
                  isProcessing = true
                  coroutineScope.launch {
                    val result = withContext(Dispatchers.Default) {
                      ImageProcessingRepository.applyFilter(
                        original,
                        ImageProcessingRepository.FilterType.BLUR,
                        context
                      )
                    }
                    displayedBitmap = result
                    isProcessing = false
                  }
                }
              },
              enabled = !isProcessing
            ) {
              Text("Blur")
            }

            // 11
            Button(
              onClick = {
                originalBitmap?.let { original ->
                  isProcessing = true
                  coroutineScope.launch {
                    val result = withContext(Dispatchers.Default) {
                      ImageProcessingRepository.applyFilter(
                        original,
                        ImageProcessingRepository.FilterType.BRIGHTER,
                        context
                      )
                    }
                    displayedBitmap = result
                    isProcessing = false
                  }
                }
              },
              enabled = !isProcessing
            ) {
              Text("Brighter")
            }

            // 12
            Button(
              onClick = {
                originalBitmap?.let { original ->
                  isProcessing = true
                  coroutineScope.launch {
                    val result = withContext(Dispatchers.Default) {
                      ImageProcessingRepository.applyFilter(
                        original,
                        ImageProcessingRepository.FilterType.DARKER,
                        context
                      )
                    }
                    displayedBitmap = result
                    isProcessing = false
                  }
                }
              },
              enabled = !isProcessing
            ) {
              Text("Darker")
            }
          }
        }


        errorMessage?.let { error ->
          Text(
            text = error,
            color = MaterialTheme.colorScheme.error,
            style = MaterialTheme.typography.bodySmall
          )
        }
      }
    },
    confirmButton = {
      Button(
        onClick = {
          // 13
          val finalUri = if (displayedBitmap != null && displayedBitmap != originalBitmap) {
            saveBitmapAndGetUri(context, displayedBitmap!!)
          } else {
            photoUri
          }

          // 14
          val result = TaskRepository.addTask(
            title = title,
            description = description,
            priority = priority,
            photoUri = finalUri?.toString()
          )

          result.onSuccess {
            onDismiss()
          }.onFailure { error ->
            errorMessage = error.message ?: "Validation failed"
          }
        }
      ) {
        Text(stringResource(R.string.save))
      }
    },
    dismissButton = {
      TextButton(onClick = onDismiss) {
        Text(stringResource(R.string.cancel))
      }
    }
  )
}

// 15
private fun loadBitmapFromUri(context: Context, uri: Uri): Bitmap? {
  return try {
    // 15a
    val bitmap = context.contentResolver.openInputStream(uri)?.use { inputStream ->
      BitmapFactory.decodeStream(inputStream)
    } ?: return null

    // 15b
    correctBitmapOrientation(context, uri, bitmap)
  } catch (e: Exception) {
    e.printStackTrace()
    null
  }
}

// 16
private fun correctBitmapOrientation(context: Context, uri: Uri, bitmap: Bitmap): Bitmap {
  return try {
    context.contentResolver.openInputStream(uri)?.use { inputStream ->
      // 16a
      val exif = ExifInterface(inputStream)
      val orientation = exif.getAttributeInt(
        ExifInterface.TAG_ORIENTATION,
        ExifInterface.ORIENTATION_NORMAL
      )

      // 16b
      when (orientation) {
        ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90f)
        ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180f)
        ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270f)
        else -> bitmap
      }
    } ?: bitmap
  } catch (e: Exception) {
    e.printStackTrace()
    bitmap
  }
}

// 17
private fun rotateBitmap(bitmap: Bitmap, degrees: Float): Bitmap {
  val matrix = android.graphics.Matrix()
  matrix.postRotate(degrees)
  return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}

// 18
private fun saveBitmapAndGetUri(context: Context, bitmap: Bitmap): Uri? {
  return try {
    val photoDir = context.getExternalFilesDir("photos") ?: return null
    val file = File(photoDir, "FILTERED_${System.currentTimeMillis()}.jpg")

    FileOutputStream(file).use { out ->
      bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
    }

    Uri.fromFile(file)
  } catch (e: Exception) {
    e.printStackTrace()
    null
  }
}

What each section does:

  1. Camera state: Tracks the photo Uri and whether the camera screen should be shown.
  2. Bitmap state: Stores the original captured bitmap, the currently displayed bitmap (which may be filtered), and a processing flag.
  3. Camera screen: When showCamera is true, displays the camera permission handler and camera screen.
  4. Photo loading: Loads the captured photo as a Bitmap and initializes both original and displayed versions.
  5. Camera button: Opens the camera when tapped.
  6. Photo preview: Shows the current bitmap (original or filtered) using AsyncImage.
  7. Filter button row: Horizontal scrolling row of filter options.
  8. Original button: Resets the display to the original captured photo.
  9. Grayscale button: Applies the grayscale filter using Swift image processing.
  10. Blur button: Applies the blur filter using Swift image processing.
  11. Brighter button: Applies brightness increase using Swift image processing.
  12. Darker button: Applies brightness decrease using Swift image processing.
  13. Save logic: If a filter was applied, saves the filtered bitmap to a new file and uses that Uri. Otherwise, uses the original photo Uri.
  14. Task creation: Passes the photo Uri (original or filtered) to TaskRepository when creating the task.
  15. loadBitmapFromUri: Helper function that loads a Bitmap from a Uri (15a) and corrects its orientation using EXIF data (15b).
  16. correctBitmapOrientation: Reads EXIF orientation metadata from the image file (16a) and rotates the bitmap accordingly (16b) to ensure photos display correctly regardless of how the device was held.
  17. rotateBitmap: Helper function that applies a rotation transformation to a bitmap using Android’s Matrix.
  18. saveBitmapAndGetUri: Helper function that saves a filtered Bitmap to a file and returns its Uri.

This complete implementation integrates all the components you’ve built throughout this lesson: camera capture from Segment 2, binary data processing from Segment 3, and Swift image filters from Segment 4.

Note: Why does EXIF orientation matter? When you take a photo with a camera, the device stores orientation metadata (EXIF data) in the image file rather than physically rotating the pixel data. Without reading and applying this metadata, photos taken in portrait mode or with the device held at different angles will display rotated incorrectly. The correctBitmapOrientation function reads this EXIF data using the androidx.exifinterface library (which you added in Segment 2) and rotates the bitmap accordingly, ensuring photos always display with the correct orientation.

Via Android Studio’s Build menu choose Clean Project then build and run your app on a device or emulator with camera support.

Testing the Complete Flow

You can now verify that everything works correctly.

Test Case 1: Camera Capture

  1. Open the app and tap the floating action button.
  2. In the Add Task dialog, tap the “Add Photo” button.
  3. Grant camera permission when prompted.
  4. See the camera preview screen.
  5. Tap the capture button.

Expected result: Camera closes and the photo appears in the task dialog preview.

Camera capture flow
Camera capture flow

Test Case 2: Grayscale Filter

  1. With a photo captured, tap the “Grayscale” button.
  2. Wait for processing to complete.

Expected result: The photo converts to black and white. Colors are replaced with gray values based on the luminosity formula.

Grayscale filter
Grayscale filter

Test Case 3: Blur Filter

  1. Tap the “Original” button to reset the photo.
  2. Tap the “Blur” button.
  3. Wait for processing (this takes longer than other filters).

Expected result: The photo becomes soft-focused with edges blurred. Fine details are smoothed out.

Blur filter
Blur filter

Test Case 4: Brightness Filters

  1. Tap “Brighter”.

Expected result: The photo becomes lighter. Dark areas become more visible.

Brighter filter
Brighter filter

  1. Tap “Darker”.

Expected result: The photo becomes darker. Bright areas become more subdued.

Darker filter
Darker filter

Test Case 5: Creating the Task

  1. Fill in title and description.
  2. Apply your preferred filter.
  3. Tap “Save”.

Expected result: The task appears in the list. The filtered photo should display in the task card (you’ll implement TaskCard updates next).

Updating the Task Card

To display photos in the task list, you need to update TaskCard.kt to show the task’s photo when one exists.

The Task model now has an optional photoUri property. When present, you’ll display the photo using AsyncImage, the same image loading library you used in CreateTaskDialog.

Open app/src/main/java/com/kodeco/android/swiftsdkforandroid/taskmanager/ui/TaskCard.kt and add the photo display section after the description text and before the priority badge:

Text(
  text = task.description,
  style = MaterialTheme.typography.bodyMedium,
  color = MaterialTheme.colorScheme.onSurfaceVariant
)

// 1
if (task.photoUri.isPresent) {
  val uri = task.photoUri.get()
  Spacer(modifier = Modifier.height(12.dp))

  AsyncImage(
    model = uri,
    contentDescription = "Task photo",
    modifier = Modifier
      .fillMaxWidth()
      .height(200.dp),
    contentScale = ContentScale.Crop
  )
}

Spacer(modifier = Modifier.height(12.dp))

PriorityBadge(priority = task.getPriority(arena).rawValue)

What this section does:

  1. Conditional photo display: Checks if the task has a photoUri using the Swift Optional’s isPresent property. If a photo exists, displays it using AsyncImage with a fixed height and crop scaling to maintain consistent card sizing.

Make sure you have the required imports at the top of the file:

import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage

Now tasks with photos will display them prominently in the task list, while tasks without photos remain unchanged. Great job so far!

Common Issues and Solutions

Issue: Camera permission denied. Solution: Go to device Settings > Apps > Task Manager > Permissions and grant camera access manually.

Issue: Filters are slow on large images. Solution: The downscaleForProcessing() function limits images to 1024x1024. Check that it’s being called.

Issue: Blurred image looks pixelated. Solution: This is expected with the simple box blur algorithm. A production app would use a more sophisticated blur (Gaussian blur).

Issue: Photo doesn’t appear after capture. Solution: Check logcat for exceptions. Verify the Uri is valid and loadBitmapFromUri() succeeds.

Issue: App crashes when applying filter. Solution: Ensure the Swift library built correctly. Run ./gradlew assembleDebug and check for Swift compilation errors.

Performance Considerations

Image processing is computationally expensive. Here’s what you’re doing to manage performance:

  • Downscaling: Large images are reduced to maximum 1024x1024 pixels.
  • Background threads: Processing happens on Dispatchers.IO, not the UI thread.
  • Limited blur radius: Capped at 10 pixels to avoid excessive computation.
  • Temporary files: Files are immediately deleted after processing to save storage.

For production apps, consider:

  • Using native image processing libraries (RenderScript, CameraX effects).
  • Caching filtered versions.
  • Providing a progress indicator for slow operations.
  • Implementing incremental processing for real-time filters.

Key Takeaways

In this segment, you’ve:

  • Added camera capture to the task creation UI.
  • Implemented filter button selection interface.
  • Connected Kotlin UI to Swift image processing.
  • Used coroutines for background processing.
  • Tested the complete camera-to-filter-to-display flow.
  • Understood performance considerations for image processing.
  • Learned how to handle loading states in Compose.

Your Task Manager now supports photo capture with Swift-powered filters. Users can take photos of tasks and apply visual effects before saving, demonstrating practical platform integration between Kotlin UI and Swift business logic.

Where to Go From Here?

Congratulations on completing the UI integration for your photo-enabled Task Manager! You’ve successfully built a complete feature that spans Android’s camera APIs, custom binary data formats, Swift image processing algorithms, and a polished Compose UI.

What You’ve Accomplished:

In this lesson, you’ve created a fully functional photo capture and filtering system:

  • Android Platform Integration: Camera permissions, CameraX preview and capture, file management
  • Cross-Language Data Exchange: Custom binary format with RGBA pixel data and header encoding
  • Swift Image Processing: Grayscale conversion, box blur, and brightness adjustment algorithms
  • Production-Ready UI: Photo preview, filter selection, EXIF orientation handling, and background processing

Explore Further:

Now that you have a working implementation, consider experimenting with:

  • Additional Filters: Try implementing sepia tone, contrast adjustment, or saturation changes
  • Filter Parameters: Add sliders to control blur radius or brightness amount
  • Multiple Photos: Allow tasks to have photo galleries instead of single images
  • Filter Previews: Generate small thumbnails showing each filter’s effect before applying
  • Custom Camera UI: Enhance the camera screen with zoom controls or flash options

Review the Final Project:

Download the completed Final project from the Materials section to compare your implementation. The Final project demonstrates all the concepts from this lesson with complete, tested code that you can reference.

You’ve now built real-world Swift-Kotlin integration! The patterns you’ve learned—binary data marshaling, JNI boundary crossing, and platform-specific API handling—are fundamental skills for building sophisticated hybrid applications.

What’s Next

Your Task Manager currently stores tasks in memory, which means they disappear when the app closes. In Lesson 3: Data Persistence and Testing, you’ll add permanent storage and comprehensive testing to create a production-ready application.

What You’ll Build:

  • File-Based Persistence: Implement JSON serialization in Swift to save and load tasks from disk
  • Cross-Platform File Handling: Learn how to work with Android’s file system from Swift code
  • CRUD Operations: Complete Create, Read, Update, and Delete functionality with persistent storage
  • Comprehensive Testing: Write unit tests for Swift business logic and integration tests for the hybrid boundary
  • Error Handling: Add robust error handling for file operations and data validation

Key Concepts:

You’ll explore advanced Swift-Kotlin interoperability patterns including:

  • Swift Codable protocol for JSON serialization
  • Cross-platform file path handling and permissions
  • Testing strategies for hybrid architectures
  • Error propagation across the JNI boundary
  • State management with persistent data

By the end of Lesson 3, your Task Manager will be a fully functional app with data that survives app restarts, photo attachments that persist, and a test suite that ensures reliability as your codebase grows.

Ready to make your tasks permanent? Let’s continue to the next lesson!

See forum comments
Download course materials from Github
Previous: Implementing Image Filters in Swift Next: Conclusion