Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7fc25b3
Android: multi-connection envelopes per exchange + Play Store warning…
vitnehasil Apr 9, 2026
50e69ec
Android: UI polish - connection labels, rename, picker colors
vitnehasil Apr 10, 2026
4d70a4e
Android: plan naming, reorder + connection label tweak
vitnehasil Apr 10, 2026
614bb94
Android: multi-plan warnings + fiat min-amount fix
vitnehasil Apr 10, 2026
1e1471d
Replace em-dashes with plain hyphens, fix missing Czech diacritics
vitnehasil Apr 10, 2026
2b9d368
feat(dao): add batch displayOrder update and getMaxDisplayOrder
vitnehasil Apr 10, 2026
5e083cb
feat(plan): assign sequential displayOrder to new plans
vitnehasil Apr 10, 2026
9130212
feat(viewmodel): replace reorderPlan with reorderPlans for drag & drop
vitnehasil Apr 10, 2026
757b9fc
feat(dashboard): add drag & drop reorder with drag handle on plan cards
vitnehasil Apr 10, 2026
44da001
fix(dashboard): fix stale drag index after reorder using rememberUpda…
vitnehasil Apr 10, 2026
864b468
feat(portfolio): refactor from per-pair to per-plan views with plan t…
vitnehasil Apr 11, 2026
614ec3e
feat(portfolio-screen): replace exchange filter with per-plan chips a…
vitnehasil Apr 11, 2026
97c7af7
feat(dashboard): add disable confirmation dialog + show "Paused" for …
vitnehasil Apr 11, 2026
d1d0f69
fix(plan-details): show "Paused" instead of next execution time for d…
vitnehasil Apr 11, 2026
d820898
revert: undo per-plan portfolio pages, restore per-pair views
vitnehasil Apr 11, 2026
2d633e2
feat(portfolio): add per-plan value lines within per-pair chart views
vitnehasil Apr 11, 2026
08ffa44
feat(portfolio): replace exchange filter with plan chips, show per-pl…
vitnehasil Apr 12, 2026
0a339b4
feat(portfolio): multi-series per-plan legend (value+invested), renam…
vitnehasil Apr 12, 2026
dbb2fc2
feat(portfolio): persist selected chip across app restarts
vitnehasil Apr 12, 2026
0a3b32b
fix: sync portfolio pager with chip selection + show plan names in Ru…
vitnehasil Apr 12, 2026
f4cc5b8
fix(portfolio): use settledPage to avoid pager race condition on chip…
vitnehasil Apr 12, 2026
4ce2a36
feat(portfolio): add advanced legend section with per-plan avg buy/ac…
vitnehasil Apr 12, 2026
987b2bc
feat(portfolio): 16 distinct colors per plan-metric combo + rename "P…
vitnehasil Apr 12, 2026
f800988
fix(portfolio): allow toggling off all base series (Celkem hodnota/in…
vitnehasil Apr 12, 2026
25187ca
feat(add-plan): optional name field when creating a DCA plan
vitnehasil Apr 12, 2026
bf3bffd
fix(review): address code review findings on multi-connection envelopes
vitnehasil Apr 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.accbot.dca.recording
import androidx.test.platform.app.InstrumentationRegistry
import com.accbot.dca.data.local.CredentialsStore
import com.accbot.dca.data.local.DcaDatabase
import com.accbot.dca.data.local.ExchangeConnectionEntity
import com.accbot.dca.data.local.OnboardingPreferences
import com.accbot.dca.data.local.UserPreferences
import com.accbot.dca.domain.model.Exchange
Expand Down Expand Up @@ -46,22 +47,31 @@ class EmulatorSetupTest {
userPrefs.setSandboxMode(true)
userPrefs.setBiometricLockEnabled(false)

// 4. Save Binance sandbox credentials
val credentialsStore = CredentialsStore(context)
// 4. Save Binance sandbox credentials. CredentialsStore now needs the connection
// DAO from the sandbox database (separate file from prod).
val sandboxDb = DcaDatabase.getInstance(context, isSandbox = true)
val credentialsStore = CredentialsStore(context, sandboxDb.exchangeConnectionDao())
val credentials = ExchangeCredentials(
exchange = Exchange.BINANCE,
apiKey = "EHF3PoIyxgXkJa1iUy7OsGPqtu7eSi6dis9O9QOBZL9SUXp16ThTyPHcIGc5ZidW",
apiSecret = "pg6Xj5bBJUer1OnFsy6kanNK9YW6A5Xk6hsjp5AEMxEgum0Yqf7vbkpDg0MbZNHo"
)
val saved = credentialsStore.saveCredentials(credentials, isSandbox = true)
assert(saved) { "Failed to save Binance sandbox credentials" }

// DCA plan is NOT created here — it will be created via UI in ForegroundServiceDemoTest
kotlinx.coroutines.runBlocking {
// Create a default Binance connection then save credentials under it.
val binanceConnectionId = sandboxDb.exchangeConnectionDao().insert(
ExchangeConnectionEntity(exchange = Exchange.BINANCE, name = "")
)
val saved = credentialsStore.saveCredentials(binanceConnectionId, credentials, isSandbox = true)
assert(saved) { "Failed to save Binance sandbox credentials" }

// Verify setup
val hasCredentials = credentialsStore.hasCredentials(Exchange.BINANCE, isSandbox = true)
assert(hasCredentials) { "Binance sandbox credentials not found after save" }
assert(userPrefs.isSandboxMode()) { "Sandbox mode not enabled" }
assert(onboarding.isOnboardingCompleted()) { "Onboarding not marked as completed" }
// DCA plan is NOT created here - it will be created via UI in ForegroundServiceDemoTest

// Verify setup
val hasCredentials = credentialsStore.hasCredentials(binanceConnectionId, isSandbox = true)
assert(hasCredentials) { "Binance sandbox credentials not found after save" }
assert(userPrefs.isSandboxMode()) { "Sandbox mode not enabled" }
assert(onboarding.isOnboardingCompleted()) { "Onboarding not marked as completed" }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.accbot.dca.data.local.DailyPriceEntity
import com.accbot.dca.data.local.DcaDatabase
import com.accbot.dca.data.local.DcaPlanEntity
import com.accbot.dca.data.local.ExchangeBalanceEntity
import com.accbot.dca.data.local.ExchangeConnectionEntity
import com.accbot.dca.data.local.NotificationEntity
import com.accbot.dca.data.local.NotificationType
import com.accbot.dca.data.local.OnboardingPreferences
Expand Down Expand Up @@ -55,8 +56,8 @@ import java.time.LocalDate
* includes it in screenshot filenames.
*
* Produces 8 screenshots per run:
* 00_welcome_{locale} Welcome/onboarding screen (clean install)
* 01–07_*_{locale} Main app screens (with populated data)
* 00_welcome_{locale} - Welcome/onboarding screen (clean install)
* 01–07_*_{locale} - Main app screens (with populated data)
*
* Run:
* ```
Expand Down Expand Up @@ -161,10 +162,10 @@ class ScreenshotCaptureTest {
composeRule.waitForIdle()
Thread.sleep(3000)

// 1. Dashboard holdings pager, active plans, Market Pulse
// 1. Dashboard - holdings pager, active plans, Market Pulse
capture("01_dashboard_$locale")

// 2. Portfolio navigate to BTC/EUR page, Price line only
// 2. Portfolio - navigate to BTC/EUR page, Price line only
clickNav(R.string.nav_portfolio)
composeRule.waitForIdle()
Thread.sleep(2000)
Expand Down Expand Up @@ -210,7 +211,7 @@ class ScreenshotCaptureTest {
Thread.sleep(500)
capture("05_settings_$locale")

// 6. Plan Details navigate to Dashboard, tap BTC plan card
// 6. Plan Details - navigate to Dashboard, tap BTC plan card
clickNav(R.string.nav_dashboard)
composeRule.waitForIdle()
Thread.sleep(500)
Expand All @@ -232,7 +233,7 @@ class ScreenshotCaptureTest {
Thread.sleep(3000)
capture("06_plan_details_$locale")

// 7. History back via TopAppBar arrow (device.pressBack exits app on API 36)
// 7. History - back via TopAppBar arrow (device.pressBack exits app on API 36)
val backLabel = composeRule.activity.getString(R.string.common_back)
composeRule.onNode(hasContentDescription(backLabel) and hasClickAction()).performClick()
composeRule.waitForIdle()
Expand Down Expand Up @@ -295,27 +296,39 @@ class ScreenshotCaptureTest {
prefs.setMarketPulseEnabled(true)
prefs.setMarketPulseExpanded(true)

val creds = CredentialsStore(context)
creds.saveCredentials(
ExchangeCredentials(Exchange.COINMATE, "demo_key", "demo_secret", clientId = "12345"),
isSandbox = false
)
creds.saveCredentials(
ExchangeCredentials(Exchange.BINANCE, "demo_key", "demo_secret"),
isSandbox = false
)

val db = DcaDatabase.getInstance(context, isSandbox = false)
val creds = CredentialsStore(context, db.exchangeConnectionDao())

db.dcaPlanDao().deleteAllPlans()
db.transactionDao().deleteAllTransactions()
db.dailyPriceDao().deleteAllPrices()
db.exchangeBalanceDao().deleteAllBalances()
db.notificationDao().deleteAllNotifications()

// Insert default connections first (one per exchange used by the screenshots)
val coinmateConnectionId = db.exchangeConnectionDao().insert(
ExchangeConnectionEntity(exchange = Exchange.COINMATE, name = "")
)
val binanceConnectionId = db.exchangeConnectionDao().insert(
ExchangeConnectionEntity(exchange = Exchange.BINANCE, name = "")
)

// Credentials (dummy - app won't call APIs during screenshots).
creds.saveCredentials(
connectionId = coinmateConnectionId,
credentials = ExchangeCredentials(Exchange.COINMATE, "demo_key", "demo_secret", clientId = "12345"),
isSandbox = false
)
creds.saveCredentials(
connectionId = binanceConnectionId,
credentials = ExchangeCredentials(Exchange.BINANCE, "demo_key", "demo_secret"),
isSandbox = false
)

val btcPlanId = db.dcaPlanDao().insertPlan(
DcaPlanEntity(
exchange = Exchange.COINMATE, crypto = "BTC", fiat = "EUR",
exchange = Exchange.COINMATE, connectionId = coinmateConnectionId,
crypto = "BTC", fiat = "EUR",
amount = BigDecimal("50"), frequency = DcaFrequency.DAILY,
strategy = DcaStrategy.Classic, isEnabled = true,
withdrawalEnabled = true,
Expand All @@ -327,7 +340,8 @@ class ScreenshotCaptureTest {
)
val ethPlanId = db.dcaPlanDao().insertPlan(
DcaPlanEntity(
exchange = Exchange.BINANCE, crypto = "ETH", fiat = "EUR",
exchange = Exchange.BINANCE, connectionId = binanceConnectionId,
crypto = "ETH", fiat = "EUR",
amount = BigDecimal("30"), frequency = DcaFrequency.WEEKLY,
strategy = DcaStrategy.FearAndGreed(), isEnabled = true,
createdAt = now.minus(Duration.ofDays(120)),
Expand Down Expand Up @@ -401,10 +415,10 @@ class ScreenshotCaptureTest {

db.exchangeBalanceDao().insertBalances(
listOf(
ExchangeBalanceEntity("COINMATE_BTC", Exchange.COINMATE, "BTC", totalBtcAccumulated, now),
ExchangeBalanceEntity("COINMATE_EUR", Exchange.COINMATE, "EUR", BigDecimal("142.50"), now),
ExchangeBalanceEntity("BINANCE_ETH", Exchange.BINANCE, "ETH", totalEthAccumulated, now),
ExchangeBalanceEntity("BINANCE_EUR", Exchange.BINANCE, "EUR", BigDecimal("85.00"), now),
ExchangeBalanceEntity(coinmateConnectionId, "BTC", Exchange.COINMATE, totalBtcAccumulated, now),
ExchangeBalanceEntity(coinmateConnectionId, "EUR", Exchange.COINMATE, BigDecimal("142.50"), now),
ExchangeBalanceEntity(binanceConnectionId, "ETH", Exchange.BINANCE, totalEthAccumulated, now),
ExchangeBalanceEntity(binanceConnectionId, "EUR", Exchange.BINANCE, BigDecimal("85.00"), now),
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.accbot.dca.data.local.UserPreferences
import com.accbot.dca.data.local.DailyPriceEntity
import com.accbot.dca.data.local.DcaPlanEntity
import com.accbot.dca.data.local.ExchangeBalanceEntity
import com.accbot.dca.data.local.ExchangeConnectionEntity
import com.accbot.dca.data.local.NotificationEntity
import com.accbot.dca.data.local.TransactionEntity
import com.accbot.dca.domain.model.DcaFrequency
Expand Down Expand Up @@ -57,32 +58,44 @@ class ScreenshotSetupTest {
prefs.setMarketPulseEnabled(true)
prefs.setMarketPulseExpanded(true)

// 2. Credentials (dummy — app won't call APIs during screenshots)
val creds = CredentialsStore(context)
creds.saveCredentials(
ExchangeCredentials(Exchange.COINMATE, "demo_key", "demo_secret", clientId = "12345"),
isSandbox = false
)
creds.saveCredentials(
ExchangeCredentials(Exchange.BINANCE, "demo_key", "demo_secret"),
isSandbox = false
)

// 3. Room DB — prod database
// 2. Room DB - prod database (constructed first because CredentialsStore needs the DAO)
val db = DcaDatabase.getInstance(context, isSandbox = false)
val creds = CredentialsStore(context, db.exchangeConnectionDao())

runBlocking {
// Clean slate
// Clean slate (also clears any prior connections so unique index doesn't trip)
db.dcaPlanDao().deleteAllPlans()
db.transactionDao().deleteAllTransactions()
db.dailyPriceDao().deleteAllPrices()
db.exchangeBalanceDao().deleteAllBalances()
db.notificationDao().deleteAllNotifications()

// Insert default connections first (one per exchange used by the screenshots)
val coinmateConnectionId = db.exchangeConnectionDao().insert(
ExchangeConnectionEntity(exchange = Exchange.COINMATE, name = "")
)
val binanceConnectionId = db.exchangeConnectionDao().insert(
ExchangeConnectionEntity(exchange = Exchange.BINANCE, name = "")
)

// Credentials (dummy - app won't call APIs during screenshots).
// Use the connection-keyed API directly to avoid the legacy shim's auto-create.
creds.saveCredentials(
connectionId = coinmateConnectionId,
credentials = ExchangeCredentials(Exchange.COINMATE, "demo_key", "demo_secret", clientId = "12345"),
isSandbox = false
)
creds.saveCredentials(
connectionId = binanceConnectionId,
credentials = ExchangeCredentials(Exchange.BINANCE, "demo_key", "demo_secret"),
isSandbox = false
)

// Insert plans
val btcPlanId = db.dcaPlanDao().insertPlan(
DcaPlanEntity(
exchange = Exchange.COINMATE, crypto = "BTC", fiat = "EUR",
exchange = Exchange.COINMATE, connectionId = coinmateConnectionId,
crypto = "BTC", fiat = "EUR",
amount = BigDecimal("50"), frequency = DcaFrequency.DAILY,
strategy = DcaStrategy.Classic, isEnabled = true,
withdrawalEnabled = true,
Expand All @@ -94,7 +107,8 @@ class ScreenshotSetupTest {
)
val ethPlanId = db.dcaPlanDao().insertPlan(
DcaPlanEntity(
exchange = Exchange.BINANCE, crypto = "ETH", fiat = "EUR",
exchange = Exchange.BINANCE, connectionId = binanceConnectionId,
crypto = "ETH", fiat = "EUR",
amount = BigDecimal("30"), frequency = DcaFrequency.WEEKLY,
strategy = DcaStrategy.FearAndGreed(), isEnabled = true,
createdAt = now.minus(Duration.ofDays(120)),
Expand All @@ -103,7 +117,7 @@ class ScreenshotSetupTest {
)
)

// Daily prices real historical data from CryptoCompare
// Daily prices - real historical data from CryptoCompare
val totalDays = HistoricalPrices.BTC_EUR.size // 201

val btcPrices = HistoricalPrices.BTC_EUR.mapIndexed { i, price ->
Expand All @@ -126,7 +140,7 @@ class ScreenshotSetupTest {
}
db.dailyPriceDao().insertPrices(ethPrices)

// BTC transactions daily over 180 days
// BTC transactions - daily over 180 days
val btcTxCount = 180
val btcTransactions = (0 until btcTxCount).map { i ->
val daysAgo = btcTxCount.toLong() - i
Expand All @@ -146,7 +160,7 @@ class ScreenshotSetupTest {
}
db.transactionDao().insertTransactions(btcTransactions)

// ETH transactions weekly over 180 days = ~26 transactions
// ETH transactions - weekly over 180 days = ~26 transactions
val ethTxCount = 26
val ethTransactions = (0 until ethTxCount).map { i ->
val daysAgo = btcTxCount.toLong() - (i * 7).toLong()
Expand All @@ -166,16 +180,16 @@ class ScreenshotSetupTest {
}
db.transactionDao().insertTransactions(ethTransactions)

// Exchange balances calculated from accumulated crypto
// Exchange balances - calculated from accumulated crypto
val totalBtcAccumulated = btcTransactions.sumOf { it.cryptoAmount }
val totalEthAccumulated = ethTransactions.sumOf { it.cryptoAmount }

db.exchangeBalanceDao().insertBalances(
listOf(
ExchangeBalanceEntity("COINMATE_BTC", Exchange.COINMATE, "BTC", totalBtcAccumulated, now),
ExchangeBalanceEntity("COINMATE_EUR", Exchange.COINMATE, "EUR", BigDecimal("142.50"), now),
ExchangeBalanceEntity("BINANCE_ETH", Exchange.BINANCE, "ETH", totalEthAccumulated, now),
ExchangeBalanceEntity("BINANCE_EUR", Exchange.BINANCE, "EUR", BigDecimal("85.00"), now),
ExchangeBalanceEntity(coinmateConnectionId, "BTC", Exchange.COINMATE, totalBtcAccumulated, now),
ExchangeBalanceEntity(coinmateConnectionId, "EUR", Exchange.COINMATE, BigDecimal("142.50"), now),
ExchangeBalanceEntity(binanceConnectionId, "ETH", Exchange.BINANCE, totalEthAccumulated, now),
ExchangeBalanceEntity(binanceConnectionId, "EUR", Exchange.BINANCE, BigDecimal("85.00"), now),
)
)

Expand Down Expand Up @@ -220,12 +234,12 @@ class ScreenshotSetupTest {
isRead = true, createdAt = now.minus(Duration.ofDays(5))
)
)
}

// Verify
assert(OnboardingPreferences(context).isOnboardingCompleted()) { "Onboarding not completed" }
assert(!UserPreferences(context).isSandboxMode()) { "Sandbox mode should be off" }
assert(CredentialsStore(context).hasCredentials(Exchange.COINMATE, isSandbox = false)) { "Coinmate credentials missing" }
assert(CredentialsStore(context).hasCredentials(Exchange.BINANCE, isSandbox = false)) { "Binance credentials missing" }
// Verify (inside runBlocking so we can use connectionIds + suspend hasCredentials)
assert(OnboardingPreferences(context).isOnboardingCompleted()) { "Onboarding not completed" }
assert(!UserPreferences(context).isSandboxMode()) { "Sandbox mode should be off" }
assert(creds.hasCredentials(coinmateConnectionId, isSandbox = false)) { "Coinmate credentials missing" }
assert(creds.hasCredentials(binanceConnectionId, isSandbox = false)) { "Binance credentials missing" }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.accbot.dca.data.local.CredentialsStore
import com.accbot.dca.data.local.DcaDatabase
import com.accbot.dca.data.local.UserPreferences
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import javax.inject.Inject

/**
Expand All @@ -23,6 +28,9 @@ class AccBotApplication : Application(), Configuration.Provider {
@Inject
lateinit var userPreferences: UserPreferences

@Inject
lateinit var credentialsStore: CredentialsStore

override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
Expand All @@ -38,6 +46,29 @@ class AccBotApplication : Application(), Configuration.Provider {
if (tag.isNotEmpty()) {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(tag))
}

// CredentialsStore v2→v3 migration: re-key credentials from
// `credentials_${env}_${EXCHANGE}` to `credentials_v3_${env}_${connectionId}`.
// Needs Room DB access (to look up the connection per exchange) so it can't run
// inside the encryptedPrefs lazy init. Idempotent - safe to call every launch.
//
// BLOCKING: must complete before any background worker (DcaWorker) tries to load
// credentials by connectionId. The migration is fast (single-digit milliseconds for
// ~14 keys) and runs once per upgrade - acceptable startup cost. The previous
// background-launch version had a race window where the alarm-triggered DcaWorker
// could fire between Room migration and CredentialsStore migration completion,
// failing to find credentials under the new key.
runBlocking {
try {
withContext(Dispatchers.IO) {
val prodDb = DcaDatabase.getInstance(this@AccBotApplication, isSandbox = false)
val sandboxDb = DcaDatabase.getInstance(this@AccBotApplication, isSandbox = true)
credentialsStore.ensureMigrated(prodDb, sandboxDb)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to run CredentialsStore migration", e)
}
}
}

companion object {
Expand Down
Loading