Initial implementation of Tilt-Shift Camera Android app

A dedicated camera app for tilt-shift photography with:
- Real-time OpenGL ES 2.0 shader-based blur preview
- Touch gesture controls (drag, rotate, pinch) for adjusting effect
- CameraX integration for camera preview and high-res capture
- EXIF metadata with GPS location support
- MediaStore integration for saving to gallery
- Jetpack Compose UI with haptic feedback

Tech stack: Kotlin, CameraX, OpenGL ES 2.0, Jetpack Compose
Min SDK: 26 (Android 8.0), Target SDK: 35 (Android 15)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-01-28 15:26:41 +01:00
commit 07e10ac9c3
38 changed files with 3489 additions and 0 deletions

View file

@ -0,0 +1,96 @@
package no.naiv.tiltshift.storage
import android.location.Location
import androidx.exifinterface.media.ExifInterface
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Writes EXIF metadata to captured images.
*/
class ExifWriter {
private val dateTimeFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
/**
* Writes EXIF data to the specified image file.
*/
fun writeExifData(
file: File,
orientation: Int,
location: Location?,
make: String = "Android",
model: String = android.os.Build.MODEL
) {
try {
val exif = ExifInterface(file)
// Orientation
exif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
// Date/time
val dateTime = dateTimeFormat.format(Date())
exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime)
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime)
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, dateTime)
// Camera info
exif.setAttribute(ExifInterface.TAG_MAKE, make)
exif.setAttribute(ExifInterface.TAG_MODEL, model)
exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Tilt-Shift Camera")
// GPS location
if (location != null) {
setLocationExif(exif, location)
}
exif.saveAttributes()
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun setLocationExif(exif: ExifInterface, location: Location) {
// Latitude
val latitude = location.latitude
val latRef = if (latitude >= 0) "N" else "S"
exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, convertToDMS(Math.abs(latitude)))
exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, latRef)
// Longitude
val longitude = location.longitude
val lonRef = if (longitude >= 0) "E" else "W"
exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, convertToDMS(Math.abs(longitude)))
exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, lonRef)
// Altitude
if (location.hasAltitude()) {
val altitude = location.altitude
val altRef = if (altitude >= 0) "0" else "1"
exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE, "${Math.abs(altitude).toLong()}/1")
exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE_REF, altRef)
}
// Timestamp
val gpsTimeFormat = SimpleDateFormat("HH:mm:ss", Locale.US)
val gpsDateFormat = SimpleDateFormat("yyyy:MM:dd", Locale.US)
val timestamp = Date(location.time)
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, gpsTimeFormat.format(timestamp))
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, gpsDateFormat.format(timestamp))
}
/**
* Converts decimal degrees to DMS (degrees/minutes/seconds) format for EXIF.
*/
private fun convertToDMS(coordinate: Double): String {
val degrees = coordinate.toInt()
val minutesDecimal = (coordinate - degrees) * 60
val minutes = minutesDecimal.toInt()
val seconds = (minutesDecimal - minutes) * 60
// EXIF format: "degrees/1,minutes/1,seconds/1000"
return "$degrees/1,$minutes/1,${(seconds * 1000).toLong()}/1000"
}
}

View file

@ -0,0 +1,186 @@
package no.naiv.tiltshift.storage
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.location.Location
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Result of a photo save operation.
*/
sealed class SaveResult {
data class Success(val uri: Uri, val path: String) : SaveResult()
data class Error(val message: String, val exception: Exception? = null) : SaveResult()
}
/**
* Handles saving captured photos to the device gallery.
*/
class PhotoSaver(private val context: Context) {
private val exifWriter = ExifWriter()
private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
/**
* Saves a bitmap with the tilt-shift effect to the gallery.
*/
suspend fun saveBitmap(
bitmap: Bitmap,
orientation: Int,
location: Location?
): SaveResult = withContext(Dispatchers.IO) {
try {
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
// Create content values for MediaStore
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
// Insert into MediaStore
val contentResolver = context.contentResolver
val uri = contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry")
// Write bitmap to output stream
contentResolver.openOutputStream(uri)?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)
} ?: return@withContext SaveResult.Error("Failed to open output stream")
// Write EXIF data
writeExifToUri(uri, orientation, location)
// Mark as complete (API 29+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(uri, contentValues, null, null)
}
// Get the file path for display
val path = getPathFromUri(uri)
SaveResult.Success(uri, path)
} catch (e: Exception) {
SaveResult.Error("Failed to save photo: ${e.message}", e)
}
}
/**
* Saves a JPEG file (from CameraX ImageCapture) to the gallery.
*/
suspend fun saveJpegFile(
sourceFile: File,
orientation: Int,
location: Location?
): SaveResult = withContext(Dispatchers.IO) {
try {
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val contentResolver = context.contentResolver
val uri = contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry")
// Copy file to MediaStore
contentResolver.openOutputStream(uri)?.use { outputStream ->
sourceFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
} ?: return@withContext SaveResult.Error("Failed to open output stream")
// Write EXIF data
writeExifToUri(uri, orientation, location)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(uri, contentValues, null, null)
}
// Clean up source file
sourceFile.delete()
val path = getPathFromUri(uri)
SaveResult.Success(uri, path)
} catch (e: Exception) {
SaveResult.Error("Failed to save photo: ${e.message}", e)
}
}
private fun writeExifToUri(uri: Uri, orientation: Int, location: Location?) {
try {
context.contentResolver.openFileDescriptor(uri, "rw")?.use { pfd ->
val exif = ExifInterface(pfd.fileDescriptor)
exif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Tilt-Shift Camera")
exif.setAttribute(ExifInterface.TAG_MAKE, "Android")
exif.setAttribute(ExifInterface.TAG_MODEL, Build.MODEL)
val dateFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
val dateTime = dateFormat.format(Date())
exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime)
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime)
location?.let { loc ->
exif.setGpsInfo(loc)
}
exif.saveAttributes()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun getPathFromUri(uri: Uri): String {
val projection = arrayOf(MediaStore.Images.Media.DATA)
context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
return cursor.getString(columnIndex) ?: uri.toString()
}
}
return uri.toString()
}
}