merge: integrate multi-account support (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 11:28:07 +01:00
commit b976ceb9df
No known key found for this signature in database
17 changed files with 1576 additions and 120 deletions

View file

@ -8,7 +8,7 @@ import androidx.activity.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue
import androidx.navigation.compose.rememberNavController
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.ui.navigation.Routes
import com.swoosh.microblog.ui.navigation.SwooshNavGraph
import com.swoosh.microblog.ui.theme.SwooshTheme
@ -22,8 +22,8 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val credentials = CredentialsManager(this)
val startDestination = if (credentials.isConfigured) Routes.FEED else Routes.SETUP
val accountManager = AccountManager(this)
val startDestination = if (accountManager.isConfigured) Routes.FEED else Routes.SETUP
setContent {
val themeMode by themeViewModel.themeMode.collectAsStateWithLifecycle()

View file

@ -0,0 +1,214 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.swoosh.microblog.data.model.GhostAccount
import java.util.UUID
class AccountManager private constructor(
private val prefs: SharedPreferences,
private val shouldMigrate: Boolean = true
) {
private val gson = Gson()
companion object {
const val KEY_ACCOUNTS = "ghost_accounts"
const val KEY_ACTIVE_ACCOUNT_ID = "active_account_id"
const val MAX_ACCOUNTS = 5
// Legacy keys for migration
private const val LEGACY_KEY_GHOST_URL = "ghost_url"
private const val LEGACY_KEY_ADMIN_API_KEY = "admin_api_key"
private const val KEY_MIGRATION_DONE = "multi_account_migration_done"
private fun createEncryptedPrefs(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
"swoosh_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
}
constructor(context: Context) : this(createEncryptedPrefs(context), shouldMigrate = true)
/** Constructor for testing with plain SharedPreferences */
constructor(prefs: SharedPreferences) : this(prefs, shouldMigrate = true)
init {
if (shouldMigrate) {
migrateIfNeeded()
}
}
private fun migrateIfNeeded() {
if (prefs.getBoolean(KEY_MIGRATION_DONE, false)) return
val legacyUrl = prefs.getString(LEGACY_KEY_GHOST_URL, null)
val legacyKey = prefs.getString(LEGACY_KEY_ADMIN_API_KEY, null)
if (!legacyUrl.isNullOrBlank() && !legacyKey.isNullOrBlank()) {
val account = GhostAccount(
id = UUID.randomUUID().toString(),
name = extractBlogName(legacyUrl),
blogUrl = legacyUrl,
apiKey = legacyKey,
isActive = true,
colorIndex = 0
)
val accounts = listOf(account)
saveAccountsList(accounts)
prefs.edit().putString(KEY_ACTIVE_ACCOUNT_ID, account.id).apply()
// Remove legacy keys
prefs.edit()
.remove(LEGACY_KEY_GHOST_URL)
.remove(LEGACY_KEY_ADMIN_API_KEY)
.apply()
}
prefs.edit().putBoolean(KEY_MIGRATION_DONE, true).apply()
}
fun getAccounts(): List<GhostAccount> {
val json = prefs.getString(KEY_ACCOUNTS, null) ?: return emptyList()
val type = object : TypeToken<List<GhostAccount>>() {}.type
return try {
val accounts: List<GhostAccount> = gson.fromJson(json, type)
val activeId = prefs.getString(KEY_ACTIVE_ACCOUNT_ID, null)
accounts.map { it.copy(isActive = it.id == activeId) }
} catch (e: Exception) {
emptyList()
}
}
fun getActiveAccount(): GhostAccount? {
val activeId = prefs.getString(KEY_ACTIVE_ACCOUNT_ID, null) ?: return null
return getAccounts().find { it.id == activeId }
}
fun addAccount(name: String, blogUrl: String, apiKey: String): Result<GhostAccount> {
val accounts = getAccounts()
if (accounts.size >= MAX_ACCOUNTS) {
return Result.failure(IllegalStateException("Maximum of $MAX_ACCOUNTS accounts reached"))
}
val colorIndex = nextAvailableColorIndex(accounts)
val account = GhostAccount(
id = UUID.randomUUID().toString(),
name = name.ifBlank { extractBlogName(blogUrl) },
blogUrl = blogUrl,
apiKey = apiKey,
isActive = true,
colorIndex = colorIndex
)
val updatedAccounts = accounts + account
saveAccountsList(updatedAccounts)
setActiveAccount(account.id)
return Result.success(account)
}
fun removeAccount(id: String): Boolean {
val accounts = getAccounts().toMutableList()
val removed = accounts.removeAll { it.id == id }
if (!removed) return false
saveAccountsList(accounts)
// If the removed account was active, switch to first available
val activeId = prefs.getString(KEY_ACTIVE_ACCOUNT_ID, null)
if (activeId == id) {
val nextAccount = accounts.firstOrNull()
if (nextAccount != null) {
setActiveAccount(nextAccount.id)
} else {
prefs.edit().remove(KEY_ACTIVE_ACCOUNT_ID).apply()
}
}
return true
}
fun setActiveAccount(id: String): Boolean {
val accounts = getAccounts()
if (accounts.none { it.id == id }) return false
prefs.edit().putString(KEY_ACTIVE_ACCOUNT_ID, id).apply()
return true
}
fun updateAccount(id: String, name: String? = null, blogUrl: String? = null, apiKey: String? = null): Boolean {
val accounts = getAccounts().toMutableList()
val index = accounts.indexOfFirst { it.id == id }
if (index == -1) return false
val current = accounts[index]
accounts[index] = current.copy(
name = name ?: current.name,
blogUrl = blogUrl ?: current.blogUrl,
apiKey = apiKey ?: current.apiKey
)
saveAccountsList(accounts)
return true
}
val isConfigured: Boolean
get() = getActiveAccount() != null
val hasAnyAccount: Boolean
get() = getAccounts().isNotEmpty()
fun clearAll() {
prefs.edit()
.remove(KEY_ACCOUNTS)
.remove(KEY_ACTIVE_ACCOUNT_ID)
.remove(KEY_MIGRATION_DONE)
.apply()
}
private fun saveAccountsList(accounts: List<GhostAccount>) {
// Strip isActive before saving since it's derived from KEY_ACTIVE_ACCOUNT_ID
val toSave = accounts.map { it.copy(isActive = false) }
val json = gson.toJson(toSave)
prefs.edit().putString(KEY_ACCOUNTS, json).apply()
}
private fun nextAvailableColorIndex(accounts: List<GhostAccount>): Int {
val usedIndices = accounts.map { it.colorIndex }.toSet()
for (i in 0 until GhostAccount.ACCOUNT_COLORS.size) {
if (i !in usedIndices) return i
}
return accounts.size % GhostAccount.ACCOUNT_COLORS.size
}
internal fun extractBlogName(url: String): String {
return try {
val cleaned = url.trim()
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/")
val host = cleaned.split("/").first()
// Remove common suffixes
host.removeSuffix(".ghost.io")
.removeSuffix(".com")
.removeSuffix(".org")
.removeSuffix(".net")
.replaceFirstChar { it.uppercase() }
} catch (e: Exception) {
"My Blog"
}
}
}

View file

@ -1,41 +1,34 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Legacy compatibility wrapper. Now delegates to AccountManager.
* Kept for backward compatibility during migration.
*/
class CredentialsManager(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
"swoosh_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
private val accountManager = AccountManager(context)
var ghostUrl: String?
get() = prefs.getString(KEY_GHOST_URL, null)
set(value) = prefs.edit().putString(KEY_GHOST_URL, value).apply()
get() = accountManager.getActiveAccount()?.blogUrl
set(_) {
// No-op: use AccountManager directly for mutations
}
var adminApiKey: String?
get() = prefs.getString(KEY_ADMIN_API_KEY, null)
set(value) = prefs.edit().putString(KEY_ADMIN_API_KEY, value).apply()
get() = accountManager.getActiveAccount()?.apiKey
set(_) {
// No-op: use AccountManager directly for mutations
}
val isConfigured: Boolean
get() = !ghostUrl.isNullOrBlank() && !adminApiKey.isNullOrBlank()
get() = accountManager.isConfigured
fun clear() {
prefs.edit().clear().apply()
}
companion object {
private const val KEY_GHOST_URL = "ghost_url"
private const val KEY_ADMIN_API_KEY = "admin_api_key"
val activeAccount = accountManager.getActiveAccount()
if (activeAccount != null) {
accountManager.removeAccount(activeAccount.id)
}
}
}

View file

@ -26,6 +26,7 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE local_posts ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
db.execSQL("ALTER TABLE local_posts ADD COLUMN imageUris TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE local_posts ADD COLUMN uploadedImageUrls TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE local_posts ADD COLUMN accountId TEXT NOT NULL DEFAULT ''")
}
}

View file

@ -21,11 +21,20 @@ interface LocalPostDao {
@Query("SELECT * FROM local_posts WHERE status = :status ORDER BY createdAt ASC")
fun getPostsByStatusOldestFirst(status: PostStatus): Flow<List<LocalPost>>
@Query("SELECT * FROM local_posts WHERE accountId = :accountId ORDER BY updatedAt DESC")
fun getPostsByAccount(accountId: String): Flow<List<LocalPost>>
@Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC")
suspend fun getQueuedPosts(
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
): List<LocalPost>
@Query("SELECT * FROM local_posts WHERE accountId = :accountId AND queueStatus IN (:statuses) ORDER BY createdAt ASC")
suspend fun getQueuedPostsByAccount(
accountId: String,
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
): List<LocalPost>
@Query("SELECT * FROM local_posts WHERE localId = :localId")
suspend fun getPostById(localId: Long): LocalPost?

View file

@ -0,0 +1,24 @@
package com.swoosh.microblog.data.model
data class GhostAccount(
val id: String,
val name: String,
val blogUrl: String,
val apiKey: String,
val isActive: Boolean = false,
val colorIndex: Int = 0
) {
companion object {
val ACCOUNT_COLORS = listOf(
0xFF6750A4, // Purple
0xFF00796B, // Teal
0xFFD32F2F, // Red
0xFF1976D2, // Blue
0xFFF57C00, // Orange
)
fun colorForIndex(index: Int): Long {
return ACCOUNT_COLORS[index % ACCOUNT_COLORS.size]
}
}
}

View file

@ -68,6 +68,7 @@ data class LocalPost(
@PrimaryKey(autoGenerate = true)
val localId: Long = 0,
val ghostId: String? = null,
val accountId: String = "",
val title: String = "",
val content: String = "",
val htmlContent: String? = null,

View file

@ -2,7 +2,7 @@ package com.swoosh.microblog.data.repository
import android.content.Context
import android.net.Uri
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.api.GhostApiService
import com.swoosh.microblog.data.db.AppDatabase
@ -22,12 +22,17 @@ import java.io.FileOutputStream
class PostRepository(private val context: Context) {
private val credentials = CredentialsManager(context)
private val accountManager = AccountManager(context)
private val dao: LocalPostDao = AppDatabase.getInstance(context).localPostDao()
fun getActiveAccountId(): String {
return accountManager.getActiveAccount()?.id ?: ""
}
private fun getApi(): GhostApiService {
val url = credentials.ghostUrl ?: throw IllegalStateException("Ghost URL not configured")
return ApiClient.getService(url) { credentials.adminApiKey }
val account = accountManager.getActiveAccount()
?: throw IllegalStateException("No active account configured")
return ApiClient.getService(account.blogUrl) { account.apiKey }
}
// --- Remote operations ---
@ -234,7 +239,14 @@ class PostRepository(private val context: Context) {
// --- Local operations ---
fun getLocalPosts(): Flow<List<LocalPost>> = dao.getAllPosts()
fun getLocalPosts(): Flow<List<LocalPost>> {
val accountId = getActiveAccountId()
return if (accountId.isNotEmpty()) {
dao.getPostsByAccount(accountId)
} else {
dao.getAllPosts()
}
}
fun getLocalPosts(filter: PostFilter, sortOrder: SortOrder): Flow<List<LocalPost>> {
val status = filter.toPostStatus()
@ -248,7 +260,14 @@ class PostRepository(private val context: Context) {
suspend fun getQueuedPosts(): List<LocalPost> = dao.getQueuedPosts()
suspend fun saveLocalPost(post: LocalPost): Long = dao.insertPost(post)
suspend fun saveLocalPost(post: LocalPost): Long {
val postWithAccount = if (post.accountId.isEmpty()) {
post.copy(accountId = getActiveAccountId())
} else {
post
}
return dao.insertPost(postWithAccount)
}
suspend fun updateLocalPost(post: LocalPost) = dao.updatePost(post)

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
@ -65,6 +66,7 @@ import coil.compose.AsyncImage
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.ShareUtils
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.GhostAccount
import com.swoosh.microblog.data.model.PostFilter
import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus
@ -79,6 +81,7 @@ fun FeedScreen(
onPostClick: (FeedPost) -> Unit,
onCompose: () -> Unit,
onEditPost: (FeedPost) -> Unit,
onAddAccount: () -> Unit = {},
viewModel: FeedViewModel = viewModel(),
themeViewModel: ThemeViewModel? = null
) {
@ -92,6 +95,8 @@ fun FeedScreen(
val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
val searchResultCount by viewModel.searchResultCount.collectAsStateWithLifecycle()
val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
@ -107,6 +112,10 @@ fun FeedScreen(
val pinnedPosts = displayPosts.filter { it.featured }
val regularPosts = displayPosts.filter { !it.featured }
var showAccountSwitcher by remember { mutableStateOf(false) }
var showDeleteConfirmation by remember { mutableStateOf<GhostAccount?>(null) }
var showRenameDialog by remember { mutableStateOf<GhostAccount?>(null) }
// Pull-to-refresh
val pullRefreshState = rememberPullRefreshState(
refreshing = state.isRefreshing,
@ -156,13 +165,53 @@ fun FeedScreen(
} else {
TopAppBar(
title = {
Column {
Text("Swoosh")
if (activeFilter != PostFilter.ALL) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { showAccountSwitcher = true }
) {
// Account color indicator
if (activeAccount != null) {
AccountAvatar(
account = activeAccount!!,
size = 28
)
Spacer(modifier = Modifier.width(8.dp))
}
Column {
Text(
text = activeFilter.displayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
text = activeAccount?.name ?: "Swoosh",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (activeAccount != null) {
Text(
text = activeAccount!!.blogUrl
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (activeFilter != PostFilter.ALL) {
Text(
text = activeFilter.displayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
if (accounts.size > 1 || accounts.isNotEmpty()) {
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = "Switch account",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@ -235,8 +284,24 @@ fun FeedScreen(
)
}
// Show recent searches when search is active but query is empty
if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) {
// Loading overlay during account switch
if (state.isSwitchingAccount) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Switching account...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else if (isSearchActive && searchQuery.isBlank() && recentSearches.isNotEmpty()) {
// Show recent searches when search is active but query is empty
RecentSearchesList(
recentSearches = recentSearches,
onSearchTap = viewModel::onRecentSearchTap,
@ -550,6 +615,86 @@ fun FeedScreen(
}
)
}
// Account switcher bottom sheet
if (showAccountSwitcher) {
AccountSwitcherBottomSheet(
accounts = accounts,
activeAccountId = activeAccount?.id,
onAccountSelected = { accountId ->
showAccountSwitcher = false
viewModel.switchAccount(accountId)
},
onAddAccount = {
showAccountSwitcher = false
onAddAccount()
},
onDeleteAccount = { account ->
showDeleteConfirmation = account
},
onRenameAccount = { account ->
showRenameDialog = account
},
onDismiss = { showAccountSwitcher = false }
)
}
// Account delete confirmation dialog
showDeleteConfirmation?.let { account ->
AlertDialog(
onDismissRequest = { showDeleteConfirmation = null },
title = { Text("Remove Account") },
text = { Text("Remove \"${account.name}\"? Local drafts for this account will be kept.") },
confirmButton = {
TextButton(
onClick = {
viewModel.removeAccount(account.id)
showDeleteConfirmation = null
if (accounts.size <= 1) {
showAccountSwitcher = false
}
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) { Text("Remove") }
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmation = null }) { Text("Cancel") }
}
)
}
// Account rename dialog
showRenameDialog?.let { account ->
var newName by remember { mutableStateOf(account.name) }
AlertDialog(
onDismissRequest = { showRenameDialog = null },
title = { Text("Rename Account") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("Account Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(
onClick = {
if (newName.isNotBlank()) {
viewModel.renameAccount(account.id, newName)
showRenameDialog = null
}
}
) { Text("Save") }
},
dismissButton = {
TextButton(onClick = { showRenameDialog = null }) { Text("Cancel") }
}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@ -804,6 +949,175 @@ fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSwitcherBottomSheet(
accounts: List<GhostAccount>,
activeAccountId: String?,
onAccountSelected: (String) -> Unit,
onAddAccount: () -> Unit,
onDeleteAccount: (GhostAccount) -> Unit,
onRenameAccount: (GhostAccount) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp)
) {
Text(
text = "Accounts",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
accounts.forEach { account ->
AccountListItem(
account = account,
isActive = account.id == activeAccountId,
onClick = { onAccountSelected(account.id) },
onDelete = { onDeleteAccount(account) },
onRename = { onRenameAccount(account) }
)
}
if (accounts.size < 5) {
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
ListItem(
headlineContent = { Text("Add account") },
leadingContent = {
Icon(
Icons.Default.Add,
contentDescription = "Add account",
tint = MaterialTheme.colorScheme.primary
)
},
modifier = Modifier.clickable(onClick = onAddAccount)
)
}
}
}
}
@Composable
fun AccountListItem(
account: GhostAccount,
isActive: Boolean,
onClick: () -> Unit,
onDelete: () -> Unit,
onRename: () -> Unit
) {
var showMenu by remember { mutableStateOf(false) }
ListItem(
headlineContent = {
Text(
account.name,
style = MaterialTheme.typography.bodyLarge,
color = if (isActive) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface
)
},
supportingContent = {
Text(
account.blogUrl
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingContent = {
AccountAvatar(account = account, size = 36)
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isActive) {
Icon(
Icons.Default.Check,
contentDescription = "Active",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
Icons.Default.Edit,
contentDescription = "More options",
modifier = Modifier.size(18.dp)
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text("Rename") },
onClick = {
showMenu = false
onRename()
},
leadingIcon = {
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp))
}
)
DropdownMenuItem(
text = { Text("Remove", color = MaterialTheme.colorScheme.error) },
onClick = {
showMenu = false
onDelete()
},
leadingIcon = {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
)
}
}
}
},
modifier = Modifier.clickable(onClick = onClick)
)
}
@Composable
fun AccountAvatar(
account: GhostAccount,
size: Int = 36
) {
val color = Color(GhostAccount.colorForIndex(account.colorIndex))
val initial = account.name.firstOrNull()?.uppercase() ?: "?"
Box(
modifier = Modifier
.size(size.dp)
.clip(CircleShape)
.background(color),
contentAlignment = Alignment.Center
) {
Text(
text = initial,
color = Color.White,
style = if (size <= 28) MaterialTheme.typography.labelSmall
else MaterialTheme.typography.labelLarge
)
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun SearchTopBar(

View file

@ -5,9 +5,11 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.FeedPreferences
import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.db.Converters
import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.PostRepository
@ -34,7 +36,8 @@ data class SnackbarEvent(
class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application)
private val accountManager = AccountManager(application)
private var repository = PostRepository(application)
private val feedPreferences = FeedPreferences(application)
private val searchHistoryManager = SearchHistoryManager(application)
@ -68,6 +71,12 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val _recentSearches = MutableStateFlow<List<String>>(emptyList())
val recentSearches: StateFlow<List<String>> = _recentSearches.asStateFlow()
private val _accounts = MutableStateFlow<List<GhostAccount>>(emptyList())
val accounts: StateFlow<List<GhostAccount>> = _accounts.asStateFlow()
private val _activeAccount = MutableStateFlow<GhostAccount?>(null)
val activeAccount: StateFlow<GhostAccount?> = _activeAccount.asStateFlow()
private var currentPage = 1
private var hasMorePages = true
private var remotePosts = listOf<FeedPost>()
@ -75,6 +84,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private var searchJob: Job? = null
init {
refreshAccountsList()
observeLocalPosts()
observeSearchQuery()
if (CredentialsManager(getApplication()).isConfigured) {
@ -101,6 +111,11 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
}
}
fun refreshAccountsList() {
_accounts.value = accountManager.getAccounts()
_activeAccount.value = accountManager.getActiveAccount()
}
private fun observeLocalPosts() {
localPostsJob?.cancel()
localPostsJob = viewModelScope.launch {
@ -201,6 +216,64 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
}
}
fun switchAccount(accountId: String) {
if (accountId == _activeAccount.value?.id) return
viewModelScope.launch {
_uiState.update { it.copy(isSwitchingAccount = true) }
accountManager.setActiveAccount(accountId)
ApiClient.reset()
// Re-create repository to pick up new account
repository = PostRepository(getApplication())
refreshAccountsList()
// Clear current state
remotePosts = emptyList()
currentPage = 1
hasMorePages = true
_uiState.update {
it.copy(
posts = emptyList(),
error = null,
isConnectionError = false,
isSwitchingAccount = false
)
}
// Re-observe local posts for new account
observeLocalPosts()
// Refresh from new account's API
refresh()
}
}
fun removeAccount(accountId: String) {
val wasActive = _activeAccount.value?.id == accountId
accountManager.removeAccount(accountId)
refreshAccountsList()
if (wasActive) {
val nextAccount = accountManager.getActiveAccount()
if (nextAccount != null) {
switchAccount(nextAccount.id)
} else {
// No accounts left
_uiState.update {
it.copy(posts = emptyList(), error = null, isConnectionError = false)
}
}
}
}
fun renameAccount(accountId: String, newName: String) {
accountManager.updateAccount(accountId, name = newName)
refreshAccountsList()
}
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) }
@ -543,6 +616,7 @@ data class FeedUiState(
val posts: List<FeedPost> = emptyList(),
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val isSwitchingAccount: Boolean = false,
val error: String? = null,
val isConnectionError: Boolean = false,
val snackbarMessage: String? = null,

View file

@ -25,6 +25,7 @@ object Routes {
const val SETTINGS = "settings"
const val STATS = "stats"
const val PREVIEW = "preview"
const val ADD_ACCOUNT = "add_account"
}
@Composable
@ -68,6 +69,9 @@ fun SwooshNavGraph(
onEditPost = { post ->
editPost = post
navController.navigate(Routes.COMPOSER)
},
onAddAccount = {
navController.navigate(Routes.ADD_ACCOUNT)
}
)
}
@ -118,7 +122,11 @@ fun SwooshNavGraph(
composable(Routes.SETTINGS) {
SettingsScreen(
themeViewModel = themeViewModel,
onBack = { navController.popBackStack() },
onBack = {
feedViewModel.refreshAccountsList()
feedViewModel.refresh()
navController.popBackStack()
},
onLogout = {
navController.navigate(Routes.SETUP) {
popUpTo(0) { inclusive = true }
@ -142,5 +150,18 @@ fun SwooshNavGraph(
onBack = { navController.popBackStack() }
)
}
composable(Routes.ADD_ACCOUNT) {
SetupScreen(
onSetupComplete = {
feedViewModel.refreshAccountsList()
feedViewModel.refresh()
navController.popBackStack()
},
onBack = {
navController.popBackStack()
}
)
}
}
}

View file

@ -2,7 +2,6 @@ package com.swoosh.microblog.ui.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@ -15,12 +14,11 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.ui.feed.AccountAvatar
import com.swoosh.microblog.ui.theme.ThemeMode
import com.swoosh.microblog.ui.theme.ThemeViewModel
@ -33,10 +31,8 @@ fun SettingsScreen(
onStatsClick: () -> Unit = {}
) {
val context = LocalContext.current
val credentials = remember { CredentialsManager(context) }
var url by remember { mutableStateOf(credentials.ghostUrl ?: "") }
var apiKey by remember { mutableStateOf(credentials.adminApiKey ?: "") }
var saved by remember { mutableStateOf(false) }
val accountManager = remember { AccountManager(context) }
val activeAccount = remember { accountManager.getActiveAccount() }
val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
@ -74,54 +70,50 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
}
// --- Ghost Instance section ---
Text("Ghost Instance", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it; saved = false },
label = { Text("Ghost URL") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth()
)
// --- Current Account section ---
Text("Current Account", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = apiKey,
onValueChange = { apiKey = it; saved = false },
label = { Text("Admin API Key") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
if (activeAccount != null) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
AccountAvatar(account = activeAccount, size = 40)
Column {
Text(
text = activeAccount.name,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = activeAccount.blogUrl
.removePrefix("https://")
.removePrefix("http://")
.removeSuffix("/"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Account count info
val accountCount = accountManager.getAccounts().size
Text(
text = "$accountCount of ${AccountManager.MAX_ACCOUNTS} accounts",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
credentials.ghostUrl = url
credentials.adminApiKey = apiKey
ApiClient.reset()
saved = true
},
modifier = Modifier.fillMaxWidth()
) {
Text("Save Changes")
}
if (saved) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Settings saved",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
@ -143,18 +135,46 @@ fun SettingsScreen(
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Disconnect current account
OutlinedButton(
onClick = {
credentials.clear()
ApiClient.reset()
onLogout()
activeAccount?.let { account ->
accountManager.removeAccount(account.id)
ApiClient.reset()
val remaining = accountManager.getAccounts()
if (remaining.isEmpty()) {
onLogout()
} else {
// There are still accounts, go back to feed with the next one
onBack()
}
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Disconnect & Reset")
Text("Disconnect Current Account")
}
Spacer(modifier = Modifier.height(8.dp))
// Disconnect all
if (accountManager.getAccounts().size > 1) {
TextButton(
onClick = {
accountManager.clearAll()
ApiClient.reset()
onLogout()
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Disconnect All Accounts")
}
}
}
}

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -38,6 +39,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun SetupScreen(
onSetupComplete: () -> Unit,
onBack: (() -> Unit)? = null,
viewModel: SetupViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
@ -72,7 +74,20 @@ fun SetupScreen(
"$withScheme/ghost/#/settings/integrations"
}
Scaffold { padding ->
Scaffold(
topBar = {
if (state.isAddingAccount && onBack != null) {
TopAppBar(
title = { Text("Add Account") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
@ -100,7 +115,7 @@ fun SetupScreen(
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Swoosh",
text = if (state.isAddingAccount) "Add Account" else "Swoosh",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary
)
@ -108,7 +123,8 @@ fun SetupScreen(
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Connect to your Ghost instance",
text = if (state.isAddingAccount) "Connect another Ghost instance"
else "Connect to your Ghost instance",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -123,6 +139,18 @@ fun SetupScreen(
.padding(bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Account name field
OutlinedTextField(
value = state.accountName,
onValueChange = viewModel::updateAccountName,
label = { Text("Account Name (optional)") },
placeholder = { Text("e.g., Personal Blog") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.url,
onValueChange = viewModel::updateUrl,
@ -219,7 +247,7 @@ fun SetupScreen(
Spacer(modifier = Modifier.width(8.dp))
Text("Testing connection...")
} else {
Text("Connect")
Text(if (state.isAddingAccount) "Add Account" else "Connect")
}
}
}
@ -288,7 +316,7 @@ private fun PulsingCirclesBackground(
val w = size.width
val h = size.height
// Circle 1 primary, upper-left
// Circle 1 -- primary, upper-left
val radius1 = w * 0.50f * scale1
drawCircle(
brush = Brush.radialGradient(
@ -300,7 +328,7 @@ private fun PulsingCirclesBackground(
center = Offset(w * 0.3f, h * 0.35f)
)
// Circle 2 tertiary, right
// Circle 2 -- tertiary, right
val radius2 = w * 0.55f * scale2
drawCircle(
brush = Brush.radialGradient(
@ -312,7 +340,7 @@ private fun PulsingCirclesBackground(
center = Offset(w * 0.75f, h * 0.45f)
)
// Circle 3 secondary, bottom-center
// Circle 3 -- secondary, bottom-center
val radius3 = w * 0.45f * scale3
drawCircle(
brush = Brush.radialGradient(

View file

@ -3,7 +3,7 @@ package com.swoosh.microblog.ui.setup
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.api.GhostJwtGenerator
import com.swoosh.microblog.data.repository.PostRepository
@ -15,11 +15,10 @@ import kotlinx.coroutines.launch
class SetupViewModel(application: Application) : AndroidViewModel(application) {
private val credentials = CredentialsManager(application)
private val accountManager = AccountManager(application)
private val _uiState = MutableStateFlow(SetupUiState(
url = credentials.ghostUrl ?: "",
apiKey = credentials.adminApiKey ?: ""
isAddingAccount = accountManager.hasAnyAccount
))
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
@ -31,6 +30,10 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(apiKey = key) }
}
fun updateAccountName(name: String) {
_uiState.update { it.copy(accountName = name) }
}
fun save() {
val state = _uiState.value
if (state.url.isBlank() || state.apiKey.isBlank()) {
@ -48,20 +51,37 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch {
_uiState.update { it.copy(isTesting = true, error = null) }
// Test connection
try {
// Validate the API key can generate a JWT
GhostJwtGenerator.generateToken(state.apiKey)
credentials.ghostUrl = state.url
credentials.adminApiKey = state.apiKey
ApiClient.reset()
val repo = PostRepository(getApplication())
repo.fetchPosts(page = 1, limit = 1).fold(
onSuccess = {
_uiState.update { it.copy(isTesting = false, isSuccess = true) }
// Add account
val result = accountManager.addAccount(
name = state.accountName,
blogUrl = state.url,
apiKey = state.apiKey
)
result.fold(
onSuccess = { account ->
// Reset ApiClient to pick up new account
ApiClient.reset()
// Test connection
val repo = PostRepository(getApplication())
repo.fetchPosts(page = 1, limit = 1).fold(
onSuccess = {
_uiState.update { it.copy(isTesting = false, isSuccess = true) }
},
onFailure = { e ->
// Remove the account since connection failed
accountManager.removeAccount(account.id)
_uiState.update { it.copy(isTesting = false, error = "Connection failed: ${e.message}") }
}
)
},
onFailure = { e ->
_uiState.update { it.copy(isTesting = false, error = "Connection failed: ${e.message}") }
_uiState.update { it.copy(isTesting = false, error = e.message) }
}
)
} catch (e: Exception) {
@ -74,7 +94,9 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
data class SetupUiState(
val url: String = "",
val apiKey: String = "",
val accountName: String = "",
val isTesting: Boolean = false,
val isSuccess: Boolean = false,
val isAddingAccount: Boolean = false,
val error: String? = null
)

View file

@ -0,0 +1,450 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
import com.swoosh.microblog.data.model.GhostAccount
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = android.app.Application::class)
class AccountManagerTest {
private lateinit var prefs: SharedPreferences
private lateinit var accountManager: AccountManager
@Before
fun setUp() {
val context = RuntimeEnvironment.getApplication()
prefs = context.getSharedPreferences("test_prefs", Context.MODE_PRIVATE)
prefs.edit().clear().apply()
accountManager = AccountManager(prefs)
}
// --- CRUD: Add Account ---
@Test
fun `addAccount creates account with correct fields`() {
val result = accountManager.addAccount("Personal Blog", "https://blog.example.com", "key_id:secret")
assertTrue(result.isSuccess)
val account = result.getOrNull()!!
assertEquals("Personal Blog", account.name)
assertEquals("https://blog.example.com", account.blogUrl)
assertEquals("key_id:secret", account.apiKey)
assertTrue(account.id.isNotBlank())
}
@Test
fun `addAccount sets new account as active`() {
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:secret1")
val active = accountManager.getActiveAccount()
assertNotNull(active)
assertEquals("Blog 1", active!!.name)
}
@Test
fun `addAccount with empty name uses extracted blog name`() {
val result = accountManager.addAccount("", "https://myblog.ghost.io", "key:secret")
assertTrue(result.isSuccess)
assertEquals("Myblog", result.getOrNull()!!.name)
}
@Test
fun `addAccount assigns unique color indices`() {
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
accountManager.addAccount("Blog 3", "https://blog3.com", "key3:s3")
val accounts = accountManager.getAccounts()
val colors = accounts.map { it.colorIndex }.toSet()
assertEquals("Each account should have a unique color", 3, colors.size)
}
@Test
fun `addAccount generates unique ids`() {
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
val accounts = accountManager.getAccounts()
val ids = accounts.map { it.id }.toSet()
assertEquals(2, ids.size)
}
// --- CRUD: Get Accounts ---
@Test
fun `getAccounts returns empty list initially`() {
assertEquals(emptyList<GhostAccount>(), accountManager.getAccounts())
}
@Test
fun `getAccounts returns all added accounts`() {
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
val accounts = accountManager.getAccounts()
assertEquals(2, accounts.size)
}
@Test
fun `getAccounts marks active account correctly`() {
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
val result2 = accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
val accounts = accountManager.getAccounts()
val activeAccount = accounts.find { it.isActive }
assertNotNull(activeAccount)
assertEquals(result2.getOrNull()!!.id, activeAccount!!.id)
}
// --- CRUD: Get Active Account ---
@Test
fun `getActiveAccount returns null when no accounts`() {
assertNull(accountManager.getActiveAccount())
}
@Test
fun `getActiveAccount returns the set active account`() {
val result1 = accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
accountManager.setActiveAccount(result1.getOrNull()!!.id)
val active = accountManager.getActiveAccount()
assertEquals("Blog 1", active!!.name)
}
// --- CRUD: Set Active Account ---
@Test
fun `setActiveAccount returns true for valid id`() {
val result = accountManager.addAccount("Blog", "https://blog.com", "key:s")
assertTrue(accountManager.setActiveAccount(result.getOrNull()!!.id))
}
@Test
fun `setActiveAccount returns false for invalid id`() {
assertFalse(accountManager.setActiveAccount("nonexistent-id"))
}
@Test
fun `setActiveAccount switches active account`() {
val result1 = accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
accountManager.setActiveAccount(result1.getOrNull()!!.id)
val active = accountManager.getActiveAccount()
assertEquals(result1.getOrNull()!!.id, active!!.id)
}
// --- CRUD: Remove Account ---
@Test
fun `removeAccount removes the specified account`() {
val result = accountManager.addAccount("Blog", "https://blog.com", "key:s")
assertTrue(accountManager.removeAccount(result.getOrNull()!!.id))
assertEquals(0, accountManager.getAccounts().size)
}
@Test
fun `removeAccount returns false for nonexistent id`() {
assertFalse(accountManager.removeAccount("nonexistent"))
}
@Test
fun `removeAccount switches active to next account when removing active`() {
val result1 = accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
val result2 = accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
// Blog 2 is now active (most recently added)
accountManager.removeAccount(result2.getOrNull()!!.id)
val active = accountManager.getActiveAccount()
assertNotNull(active)
assertEquals(result1.getOrNull()!!.id, active!!.id)
}
@Test
fun `removeAccount clears active when last account removed`() {
val result = accountManager.addAccount("Blog", "https://blog.com", "key:s")
accountManager.removeAccount(result.getOrNull()!!.id)
assertNull(accountManager.getActiveAccount())
}
@Test
fun `removeAccount does not change active when removing non-active`() {
val result1 = accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
val result2 = accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
// Blog 2 is active
accountManager.removeAccount(result1.getOrNull()!!.id)
val active = accountManager.getActiveAccount()
assertEquals(result2.getOrNull()!!.id, active!!.id)
}
// --- CRUD: Update Account ---
@Test
fun `updateAccount updates name`() {
val result = accountManager.addAccount("Old Name", "https://blog.com", "key:s")
assertTrue(accountManager.updateAccount(result.getOrNull()!!.id, name = "New Name"))
val updated = accountManager.getAccounts().find { it.id == result.getOrNull()!!.id }
assertEquals("New Name", updated!!.name)
}
@Test
fun `updateAccount updates blogUrl`() {
val result = accountManager.addAccount("Blog", "https://old.com", "key:s")
assertTrue(accountManager.updateAccount(result.getOrNull()!!.id, blogUrl = "https://new.com"))
val updated = accountManager.getAccounts().find { it.id == result.getOrNull()!!.id }
assertEquals("https://new.com", updated!!.blogUrl)
}
@Test
fun `updateAccount updates apiKey`() {
val result = accountManager.addAccount("Blog", "https://blog.com", "old_key:secret")
assertTrue(accountManager.updateAccount(result.getOrNull()!!.id, apiKey = "new_key:secret"))
val updated = accountManager.getAccounts().find { it.id == result.getOrNull()!!.id }
assertEquals("new_key:secret", updated!!.apiKey)
}
@Test
fun `updateAccount returns false for nonexistent id`() {
assertFalse(accountManager.updateAccount("nonexistent", name = "Name"))
}
@Test
fun `updateAccount preserves unchanged fields`() {
val result = accountManager.addAccount("Blog", "https://blog.com", "key:s")
accountManager.updateAccount(result.getOrNull()!!.id, name = "New Name")
val updated = accountManager.getAccounts().find { it.id == result.getOrNull()!!.id }
assertEquals("https://blog.com", updated!!.blogUrl)
assertEquals("key:s", updated.apiKey)
}
// --- Max Accounts Limit ---
@Test
fun `addAccount fails when max accounts reached`() {
for (i in 1..5) {
val r = accountManager.addAccount("Blog $i", "https://blog$i.com", "key$i:s$i")
assertTrue("Account $i should be added successfully", r.isSuccess)
}
val result = accountManager.addAccount("Blog 6", "https://blog6.com", "key6:s6")
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull()!!.message!!.contains("Maximum"))
}
@Test
fun `max accounts is 5`() {
assertEquals(5, AccountManager.MAX_ACCOUNTS)
}
@Test
fun `can add account after removing one at max`() {
val accountIds = mutableListOf<String>()
for (i in 1..5) {
val r = accountManager.addAccount("Blog $i", "https://blog$i.com", "key$i:s$i")
accountIds.add(r.getOrNull()!!.id)
}
accountManager.removeAccount(accountIds[0])
val result = accountManager.addAccount("Blog 6", "https://blog6.com", "key6:s6")
assertTrue(result.isSuccess)
}
// --- Migration ---
@Test
fun `migration from single account creates account entry`() {
// Simulate legacy state: write directly to prefs
prefs.edit()
.putString("ghost_url", "https://legacy.ghost.io")
.putString("admin_api_key", "legacy_key:legacy_secret")
.remove("multi_account_migration_done")
.apply()
// Create a new AccountManager to trigger migration
val freshManager = AccountManager(prefs)
val accounts = freshManager.getAccounts()
assertEquals(1, accounts.size)
val account = accounts[0]
assertEquals("https://legacy.ghost.io", account.blogUrl)
assertEquals("legacy_key:legacy_secret", account.apiKey)
assertTrue(account.isActive)
}
@Test
fun `migration removes legacy keys`() {
prefs.edit()
.putString("ghost_url", "https://legacy.ghost.io")
.putString("admin_api_key", "legacy_key:legacy_secret")
.remove("multi_account_migration_done")
.apply()
AccountManager(prefs)
// Legacy keys should be removed
assertNull(prefs.getString("ghost_url", null))
assertNull(prefs.getString("admin_api_key", null))
}
@Test
fun `migration only runs once`() {
prefs.edit()
.putString("ghost_url", "https://legacy.ghost.io")
.putString("admin_api_key", "legacy_key:legacy_secret")
.remove("multi_account_migration_done")
.apply()
val manager1 = AccountManager(prefs)
assertEquals(1, manager1.getAccounts().size)
// Second instantiation should not duplicate
val manager2 = AccountManager(prefs)
assertEquals(1, manager2.getAccounts().size)
}
@Test
fun `migration does nothing when no legacy credentials`() {
prefs.edit()
.remove("ghost_url")
.remove("admin_api_key")
.remove("multi_account_migration_done")
.apply()
val freshManager = AccountManager(prefs)
assertEquals(0, freshManager.getAccounts().size)
}
@Test
fun `migration extracts blog name from legacy url`() {
prefs.edit()
.putString("ghost_url", "https://myblog.ghost.io")
.putString("admin_api_key", "key:secret")
.remove("multi_account_migration_done")
.apply()
val freshManager = AccountManager(prefs)
val account = freshManager.getAccounts().first()
assertEquals("Myblog", account.name)
}
// --- isConfigured ---
@Test
fun `isConfigured returns false when no accounts`() {
assertFalse(accountManager.isConfigured)
}
@Test
fun `isConfigured returns true when active account exists`() {
accountManager.addAccount("Blog", "https://blog.com", "key:s")
assertTrue(accountManager.isConfigured)
}
// --- hasAnyAccount ---
@Test
fun `hasAnyAccount returns false when no accounts`() {
assertFalse(accountManager.hasAnyAccount)
}
@Test
fun `hasAnyAccount returns true when accounts exist`() {
accountManager.addAccount("Blog", "https://blog.com", "key:s")
assertTrue(accountManager.hasAnyAccount)
}
// --- clearAll ---
@Test
fun `clearAll removes all accounts`() {
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
accountManager.clearAll()
assertEquals(0, accountManager.getAccounts().size)
assertNull(accountManager.getActiveAccount())
assertFalse(accountManager.isConfigured)
}
// --- extractBlogName ---
@Test
fun `extractBlogName removes ghost io suffix`() {
assertEquals("Myblog", accountManager.extractBlogName("https://myblog.ghost.io"))
}
@Test
fun `extractBlogName removes com suffix`() {
assertEquals("Myblog", accountManager.extractBlogName("https://myblog.com"))
}
@Test
fun `extractBlogName handles URL without scheme`() {
assertEquals("Myblog", accountManager.extractBlogName("myblog.ghost.io"))
}
@Test
fun `extractBlogName capitalizes first letter`() {
assertEquals("Myblog", accountManager.extractBlogName("myblog.ghost.io"))
}
@Test
fun `extractBlogName handles trailing slash`() {
assertEquals("Myblog", accountManager.extractBlogName("https://myblog.ghost.io/"))
}
@Test
fun `extractBlogName handles URL with path`() {
assertEquals("Myblog", accountManager.extractBlogName("https://myblog.ghost.io/blog"))
}
// --- Account switching ---
@Test
fun `switching active account changes getActiveAccount result`() {
val r1 = accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
val r2 = accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
// Blog 2 is now active
assertEquals("Blog 2", accountManager.getActiveAccount()!!.name)
// Switch to Blog 1
accountManager.setActiveAccount(r1.getOrNull()!!.id)
assertEquals("Blog 1", accountManager.getActiveAccount()!!.name)
// Switch back to Blog 2
accountManager.setActiveAccount(r2.getOrNull()!!.id)
assertEquals("Blog 2", accountManager.getActiveAccount()!!.name)
}
@Test
fun `only one account is marked active at a time`() {
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
accountManager.addAccount("Blog 3", "https://blog3.com", "key3:s3")
val accounts = accountManager.getAccounts()
val activeCount = accounts.count { it.isActive }
assertEquals(1, activeCount)
}
@Test
fun `persisted data survives new AccountManager instance`() {
accountManager.addAccount("Blog 1", "https://blog1.com", "key1:s1")
accountManager.addAccount("Blog 2", "https://blog2.com", "key2:s2")
// Create a new instance sharing the same prefs
val newManager = AccountManager(prefs)
assertEquals(2, newManager.getAccounts().size)
assertEquals("Blog 2", newManager.getActiveAccount()!!.name)
}
}

View file

@ -0,0 +1,192 @@
package com.swoosh.microblog.data.db
import android.content.Context
import androidx.room.Room
import com.swoosh.microblog.data.model.LocalPost
import com.swoosh.microblog.data.model.PostStatus
import com.swoosh.microblog.data.model.QueueStatus
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = android.app.Application::class)
class LocalPostDaoTest {
private lateinit var database: AppDatabase
private lateinit var dao: LocalPostDao
@Before
fun setUp() {
val context: Context = RuntimeEnvironment.getApplication()
database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries()
.build()
dao = database.localPostDao()
}
@After
fun tearDown() {
database.close()
}
// --- Account-filtered queries ---
@Test
fun `getPostsByAccount returns only posts for specified account`() = runTest {
val post1 = LocalPost(
accountId = "account-1",
title = "Post for account 1",
content = "Content 1"
)
val post2 = LocalPost(
accountId = "account-2",
title = "Post for account 2",
content = "Content 2"
)
val post3 = LocalPost(
accountId = "account-1",
title = "Another post for account 1",
content = "Content 3"
)
dao.insertPost(post1)
dao.insertPost(post2)
dao.insertPost(post3)
val account1Posts = dao.getPostsByAccount("account-1").first()
assertEquals(2, account1Posts.size)
assertTrue(account1Posts.all { it.accountId == "account-1" })
}
@Test
fun `getPostsByAccount returns empty list for account with no posts`() = runTest {
val post = LocalPost(
accountId = "account-1",
title = "Post",
content = "Content"
)
dao.insertPost(post)
val posts = dao.getPostsByAccount("account-2").first()
assertEquals(0, posts.size)
}
@Test
fun `getAllPosts returns posts from all accounts`() = runTest {
dao.insertPost(LocalPost(accountId = "account-1", title = "P1", content = "C1"))
dao.insertPost(LocalPost(accountId = "account-2", title = "P2", content = "C2"))
val allPosts = dao.getAllPosts().first()
assertEquals(2, allPosts.size)
}
@Test
fun `getQueuedPostsByAccount returns only queued posts for account`() = runTest {
dao.insertPost(
LocalPost(
accountId = "account-1",
title = "Queued 1",
content = "C1",
queueStatus = QueueStatus.QUEUED_PUBLISH
)
)
dao.insertPost(
LocalPost(
accountId = "account-2",
title = "Queued 2",
content = "C2",
queueStatus = QueueStatus.QUEUED_PUBLISH
)
)
dao.insertPost(
LocalPost(
accountId = "account-1",
title = "Not queued",
content = "C3",
queueStatus = QueueStatus.NONE
)
)
val queued = dao.getQueuedPostsByAccount("account-1")
assertEquals(1, queued.size)
assertEquals("Queued 1", queued[0].title)
}
@Test
fun `getQueuedPosts returns queued posts from all accounts`() = runTest {
dao.insertPost(
LocalPost(
accountId = "account-1",
title = "Q1",
content = "C1",
queueStatus = QueueStatus.QUEUED_PUBLISH
)
)
dao.insertPost(
LocalPost(
accountId = "account-2",
title = "Q2",
content = "C2",
queueStatus = QueueStatus.QUEUED_SCHEDULED
)
)
val queued = dao.getQueuedPosts()
assertEquals(2, queued.size)
}
// --- accountId field on LocalPost ---
@Test
fun `LocalPost stores and retrieves accountId`() = runTest {
val id = dao.insertPost(
LocalPost(
accountId = "test-account-id",
title = "Test",
content = "Content"
)
)
val retrieved = dao.getPostById(id)
assertNotNull(retrieved)
assertEquals("test-account-id", retrieved!!.accountId)
}
@Test
fun `LocalPost has empty string as default accountId`() {
val post = LocalPost(title = "Test", content = "Content")
assertEquals("", post.accountId)
}
@Test
fun `getPostsByAccount orders by updatedAt descending`() = runTest {
dao.insertPost(
LocalPost(
accountId = "account-1",
title = "Older",
content = "C1",
updatedAt = 1000L
)
)
dao.insertPost(
LocalPost(
accountId = "account-1",
title = "Newer",
content = "C2",
updatedAt = 2000L
)
)
val posts = dao.getPostsByAccount("account-1").first()
assertEquals("Newer", posts[0].title)
assertEquals("Older", posts[1].title)
}
}

View file

@ -0,0 +1,74 @@
package com.swoosh.microblog.data.model
import org.junit.Assert.*
import org.junit.Test
class GhostAccountTest {
@Test
fun `GhostAccount has correct default values`() {
val account = GhostAccount(
id = "test-id",
name = "Test Blog",
blogUrl = "https://test.ghost.io",
apiKey = "key:secret"
)
assertFalse(account.isActive)
assertEquals(0, account.colorIndex)
}
@Test
fun `GhostAccount preserves all fields`() {
val account = GhostAccount(
id = "id-123",
name = "My Blog",
blogUrl = "https://myblog.com",
apiKey = "abc:def",
isActive = true,
colorIndex = 3
)
assertEquals("id-123", account.id)
assertEquals("My Blog", account.name)
assertEquals("https://myblog.com", account.blogUrl)
assertEquals("abc:def", account.apiKey)
assertTrue(account.isActive)
assertEquals(3, account.colorIndex)
}
@Test
fun `ACCOUNT_COLORS has 5 colors`() {
assertEquals(5, GhostAccount.ACCOUNT_COLORS.size)
}
@Test
fun `colorForIndex wraps around for large indices`() {
val color0 = GhostAccount.colorForIndex(0)
val color5 = GhostAccount.colorForIndex(5)
assertEquals(color0, color5)
}
@Test
fun `colorForIndex returns different colors for different indices`() {
val colors = (0 until 5).map { GhostAccount.colorForIndex(it) }.toSet()
assertEquals(5, colors.size)
}
@Test
fun `copy preserves fields correctly`() {
val original = GhostAccount(
id = "id",
name = "Name",
blogUrl = "https://blog.com",
apiKey = "key:secret",
isActive = true,
colorIndex = 2
)
val copy = original.copy(name = "New Name")
assertEquals("New Name", copy.name)
assertEquals("id", copy.id)
assertEquals("https://blog.com", copy.blogUrl)
assertEquals("key:secret", copy.apiKey)
assertTrue(copy.isActive)
assertEquals(2, copy.colorIndex)
}
}