Your app stores user data locally—but is it encrypted? On a rooted device, anyone can read your database files. That's a security nightmare.
SQLCipher encrypts your entire database. Combined with Room, you get type-safe queries AND military-grade encryption.
The Problem: Unencrypted Data
Without encryption, your database is an open book:
// ❌ PROBLEM: Anyone with file access can read this!
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: Long,
val email: String, // Exposed!
val passwordHash: String, // Exposed!
val creditCard: String // Exposed!!
)Anyone can:
- Read sensitive user data
- Modify records
- Extract passwords
- Steal credit cards
The Solution: Room + SQLCipher
SQLCipher encrypts the entire database file. Without the key, it's just random bytes:
// ✅ SOLUTION: Encrypted database!
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: Long,
val email: String, // Now encrypted!
val passwordHash: String, // Now encrypted!
val creditCard: String // Now encrypted!!
)Setup: Adding SQLCipher to Room
1. Add Dependencies
// build.gradle.kts
dependencies {
// Room core
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// SQLCipher for encryption
implementation("net.zetetic:android-database-sqlcipher:4.5.4")
implementation("androidx.sqlite:sqlite-ktx:2.4.0")
}2. Create Encrypted Database
@Database(
entities = [UserEntity::class, ClipEntity::class],
version = 1,
exportSchema = true // IMPORTANT: For migrations!
)
abstract class ClipVaultDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun clipDao(): ClipDao
companion object {
const val DATABASE_NAME = "clipvault_db"
}
}3. Provide Encrypted Database (with Hilt)
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
// ============================================================
// THE KEY: This passphrase encrypts/decrypts the database
// IMPORTANT: Store securely! (Android Keystore, not hardcoded!)
// ============================================================
@Provides
@Singleton
fun provideDatabasePassphrase(): ByteArray {
// TODO: In production, load from Android Keystore!
// For now, a static key (NOT secure!)
return "MySecretPassphrase123".toByteArray()
}
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context,
passphrase: ByteArray
): ClipVaultDatabase {
// ============================================================
// Step 1: Create SQLCipher factory with passphrase
// ============================================================
val factory = SupportFactory(passphrase)
// ============================================================
// Step 2: Build Room database with encryption factory
// ============================================================
return Room.databaseBuilder(
context,
ClipVaultDatabase::class.java,
ClipVaultDatabase.DATABASE_NAME
)
.openHelperFactory(factory) // ← Enable encryption!
.fallbackToDestructiveMigration()
.build()
}
}Secure Key Storage: Android Keystore
Never hardcode your encryption key! Use Android Keystore:
// ============================================================
// SECURE: Store encryption key in Android Keystore
// ============================================================
object SecureKeyManager {
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val KEY_ALIAS = "clipvault_db_key"
fun getOrCreateKey(context: Context): ByteArray {
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
load(null)
}
return if (keyStore.containsAlias(KEY_ALIAS)) {
// Key exists, load it
(keyStore.getKey(KEY_ALIAS, null) as SecretKey).encoded
?: generateNewKey() // Fallback
} else {
// Generate new key
generateNewKey()
}
}
private fun generateNewKey(): ByteArray {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
KEYSTORE_PROVIDER
)
// Generate 256-bit key
val keyGenSpec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
keyGenerator.init(keyGenSpec)
val key = keyGenerator.generateKey()
return key.encoded ?: ByteArray(0)
}
}Room Basics: Entities, DAOs, Queries
Entity: Define Your Table
// ============================================================
// ENTITY = "What a row looks like"
// ============================================================
@Entity(tableName = "clips")
data class ClipEntity(
// @PrimaryKey = this column is unique
@PrimaryKey(autoGenerate = true) // ← Auto-increment ID
val id: Long = 0,
// @ColumnInfo = customize column name
@ColumnInfo(name = "content")
val content: String,
// Default values
val type: String = "TEXT",
val category: String = "Personal",
val isPinned: Boolean = false,
val isFavorite: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
)DAO: Define Your Queries
// ============================================================
// DAO = "What operations you can do on the table"
// ============================================================
@Dao
interface ClipDao {
// ============================================================
// Query = Read operations
// ============================================================
// Get all clips, ordered by pinned first, then by date
@Query("SELECT * FROM clips ORDER BY isPinned DESC, createdAt DESC")
fun getAllClips(): Flow<List<ClipEntity>>
// Get clips filtered by category
@Query("SELECT * FROM clips WHERE category = :category ORDER BY createdAt DESC")
fun getClipsByCategory(category: String): Flow<List<ClipEntity>>
// Search clips by content
@Query("SELECT * FROM clips WHERE content LIKE '%' || :query || '%'")
fun searchClips(query: String): Flow<List<ClipEntity>>
// Get single clip by ID
@Query("SELECT * FROM clips WHERE id = :id")
suspend fun getClipById(id: Long): ClipEntity?
// ============================================================
// Insert = Create operations
// ============================================================
// Insert one clip, return the new ID
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertClip(clip: ClipEntity): Long
// Insert multiple clips
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertClips(clips: List<ClipEntity>)
// ============================================================
// Update = Modify operations
// ============================================================
@Update
suspend fun updateClip(clip: ClipEntity)
// ============================================================
// Delete = Remove operations
// ============================================================
@Delete
suspend fun deleteClip(clip: ClipEntity)
@Query("DELETE FROM clips WHERE id = :id")
suspend fun deleteClipById(id: Long)
@Query("DELETE FROM clips")
suspend fun deleteAllClips()
// ============================================================
// Count = Aggregate operations
// ============================================================
@Query("SELECT COUNT(*) FROM clips")
suspend fun getClipCount(): Int
}Using the DAO in Repository
// ============================================================
// REPOSITORY = "Bridge between data and domain"
// ============================================================
class ClipRepositoryImpl(
private val clipDao: ClipDao // ← Injected by Hilt
) : ClipRepository {
// Get all clips as reactive Flow
override fun getAllClips(): Flow<List<Clip>> {
return clipDao.getAllClips().map { entities ->
entities.map { it.toDomain() } // Convert Entity → Domain
}
}
// Insert new clip
override suspend fun insertClip(clip: Clip): Long {
return clipDao.insertClip(ClipEntity.fromDomain(clip))
}
// Search with Flow
override fun searchClips(query: String): Flow<List<Clip>> {
return clipDao.searchClips(query).map { entities ->
entities.map { it.toDomain() }
}
}
}Migrations: Updating Your Database
When you change your entity, you need a migration:
// ============================================================
// MIGRATION = "How to move from version 1 to version 2"
// ============================================================
// In your Database class:
@Database(
entities = [ClipEntity::class],
version = 2, // ← INCREMENT THIS!
exportSchema = true
)
abstract class ClipVaultDatabase : RoomDatabase() {
abstract fun clipDao(): ClipDao
companion object {
const val DATABASE_NAME = "clipvault_db"
// ============================================================
// Migration from version 1 to 2
// ============================================================
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// Add new column for encrypted flag
database.execSQL(
"ALTER TABLE clips ADD COLUMN isEncrypted INTEGER NOT NULL DEFAULT 0"
)
}
}
// Migration from 2 to 3 (example)
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
// Add category column with default
database.execSQL(
"ALTER TABLE clips ADD COLUMN category TEXT NOT NULL DEFAULT 'Personal'"
)
}
}
}
}
// Building with migrations:
val database = Room.databaseBuilder(
context,
ClipVaultDatabase::class.java,
ClipVaultDatabase.DATABASE_NAME
)
.addMigrations(
ClipVaultDatabase.MIGRATION_1_2,
ClipVaultDatabase.MIGRATION_2_3
)
.build()Real-World Pattern: ClipVault Repository
Here's a production-ready implementation:
// ============================================================
// DOMAIN MODEL = "What the rest of the app sees"
// ============================================================
data class Clip(
val id: Long = 0,
val content: String,
val type: ClipType = ClipType.TEXT,
val category: String = "Personal",
val isPinned: Boolean = false,
val isFavorite: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
)
enum class ClipType {
TEXT, URL, EMAIL, IMAGE
}
// ============================================================
// REPOSITORY INTERFACE = "Contract"
// ============================================================
interface ClipRepository {
fun getAllClips(): Flow<List<Clip>>
fun searchClips(query: String): Flow<List<Clip>>
suspend fun insertClip(clip: Clip): Long
suspend fun deleteClip(clip: Clip)
suspend fun togglePin(id: Long)
suspend fun getClipCount(): Int
}
// ============================================================
// REPOSITORY IMPLEMENTATION = "The actual code"
// ============================================================
class ClipRepositoryImpl(
private val clipDao: ClipDao
) : ClipRepository {
override fun getAllClips(): Flow<List<Clip>> {
return clipDao.getAllClips().map { entities ->
entities.map { it.toDomain() }
}
}
override fun searchClips(query: String): Flow<List<Clip>> {
return clipDao.searchClips(query).map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun insertClip(clip: Clip): Long {
return clipDao.insertClip(ClipEntity.fromDomain(clip))
}
override suspend fun deleteClip(clip: Clip) {
clipDao.deleteClip(ClipEntity.fromDomain(clip))
}
override suspend fun togglePin(id: Long) {
clipDao.togglePin(id)
}
override suspend fun getClipCount(): Int {
return clipDao.getClipCount()
}
}
// ============================================================
// EXTENSION FUNCTIONS = "Convert between layers"
// ============================================================
fun ClipEntity.toDomain(): Clip = Clip(
id = id,
content = content,
type = ClipType.valueOf(type),
category = category,
isPinned = isPinned,
isFavorite = isFavorite,
createdAt = createdAt,
updatedAt = updatedAt
)
fun Clip.toEntity(): ClipEntity = ClipEntity(
id = id,
content = content,
type = type.name,
category = category,
isPinned = isPinned,
isFavorite = isFavorite,
createdAt = createdAt,
updatedAt = updatedAt
)When to Use SQLCipher
✅ Use When:
- Storing sensitive data - Passwords, tokens, personal info
- Compliance requirements - GDPR, HIPAA, PCI-DSS
- Banking/finance apps - Extra security layer
- Health apps - Medical data protection
- Enterprise apps - Corporate data security
❌ Don't Overuse When:
- Simple key-value data - Use EncryptedSharedPreferences
- No sensitive data - Regular Room is fine
- Performance critical - Slight overhead (~5-10%)
Quick Decision Guide
🎯 Room Basics:
- @Entity → Define a table
- @Dao → Define queries (SELECT, INSERT, UPDATE, DELETE)
- @Database → The database itself
- @PrimaryKey → Unique identifier
- Flow → Reactive updates
🔐 SQLCipher:
- Add dependency →
android-database-sqlcipher - SupportFactory → Enable encryption
- Android Keystore → Store key securely
🔄 Migrations:
- Increment version →
version = 2 - Create Migration →
object : Migration(1, 2) - Add migration →
.addMigrations(...)
Key Takeaway
Start with unencrypted Room for prototyping, then add SQLCipher when you handle sensitive data. Always store the encryption key in Android Keystore—never hardcode it! Migrations are critical: always increment version and export schema.