Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -337,7 +337,7 @@ fun AccBotApp(
)
}

// Main screens HorizontalPager with bottom nav / nav rail
// Main screens HorizontalPager with bottom nav / nav rail
composable("main") {
var isChartTouching by remember { mutableStateOf(false) }

Expand Down Expand Up @@ -429,6 +429,9 @@ fun AccBotApp(
},
onNavigateToHistory = { crypto, fiat ->
navController.navigate(Screen.History.createRoute(crypto, fiat))
},
onNavigateToTransactionDetails = { transactionId ->
navController.navigate(Screen.TransactionDetails.createRoute(transactionId))
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ class BackupDataRestorer @Inject constructor(
val restoredNext = nextExecutionAt?.let { Instant.ofEpochMilli(it) }

val effectiveNext = if (restoredNext != null && restoredNext.isAfter(now)) {
restoredNext // still in the future keep it
restoredNext // still in the future keep it
} else if (cronExpression != null) {
CronUtils.getNextExecution(cronExpression, now)
?: now.plus(Duration.ofMinutes(freq.intervalMinutes.takeIf { it > 0 } ?: 1440))
Expand Down
17 changes: 17 additions & 0 deletions accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ interface DcaPlanDao {

@Query("SELECT * FROM dca_plans ORDER BY createdAt DESC")
suspend fun getAllPlansOnce(): List<DcaPlanEntity>

@Query("UPDATE dca_plans SET networkRetryCount = networkRetryCount + 1, nextNetworkRetryAt = :nextRetryAt, originalScheduledAt = CASE WHEN originalScheduledAt IS NULL THEN :originalScheduledAt ELSE originalScheduledAt END WHERE id = :planId")
suspend fun incrementNetworkRetry(planId: Long, nextRetryAt: Instant, originalScheduledAt: Instant)

@Query("UPDATE dca_plans SET networkRetryCount = networkRetryCount + 1, nextNetworkRetryAt = :nextRetryAt, originalScheduledAt = CASE WHEN originalScheduledAt IS NULL THEN :originalScheduledAt ELSE originalScheduledAt END WHERE id = :planId")
fun incrementNetworkRetrySync(planId: Long, nextRetryAt: Instant, originalScheduledAt: Instant)

@Query("UPDATE dca_plans SET networkRetryCount = 0, nextNetworkRetryAt = NULL, originalScheduledAt = NULL WHERE id = :planId")
suspend fun resetNetworkRetry(planId: Long)

@Query("UPDATE dca_plans SET missedPurchaseCount = :count WHERE id = :planId")
suspend fun setMissedPurchaseCount(planId: Long, count: Int)

@Query("UPDATE dca_plans SET missedPurchaseCount = 0 WHERE id = :planId")
suspend fun resetMissedPurchaseCount(planId: Long)

}

@Dao
Expand Down Expand Up @@ -418,6 +434,7 @@ interface NotificationDao {

@Query("SELECT * FROM notifications ORDER BY createdAt DESC")
suspend fun getAllNotificationsOnce(): List<NotificationEntity>

}

@Dao
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
NotificationEntity::class,
WithdrawalThresholdEntity::class
],
version = 15,
version = 18,
exportSchema = true
)
@TypeConverters(Converters::class)
Expand Down Expand Up @@ -193,6 +193,28 @@ abstract class DcaDatabase : RoomDatabase() {
}
}

// Migration from version 15 to 16: Add network retry tracking columns to dca_plans
private val MIGRATION_15_16 = object : Migration(15, 16) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE dca_plans ADD COLUMN networkRetryCount INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE dca_plans ADD COLUMN nextNetworkRetryAt INTEGER DEFAULT NULL")
}
}

// Migration from version 16 to 17: Add originalScheduledAt for delay tracking across retries
private val MIGRATION_16_17 = object : Migration(16, 17) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE dca_plans ADD COLUMN originalScheduledAt INTEGER DEFAULT NULL")
}
}

// Migration from version 17 to 18: Add missedPurchaseCount for offline recovery
private val MIGRATION_17_18 = object : Migration(17, 18) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE dca_plans ADD COLUMN missedPurchaseCount INTEGER NOT NULL DEFAULT 0")
}
}

// Migration from version 9 to 10: Add notifications and withdrawal_thresholds tables
private val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
Expand Down Expand Up @@ -295,7 +317,7 @@ abstract class DcaDatabase : RoomDatabase() {
DcaDatabase::class.java,
databaseName
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18)
// Only allow destructive migration on app downgrade, never on failed upgrade
// This protects user's transaction history from accidental deletion
.fallbackToDestructiveMigrationOnDowngrade()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import java.time.Instant
/**
* Notification type for in-app notification history
*/
enum class NotificationType { PURCHASE, ERROR, LOW_BALANCE, WITHDRAWAL_THRESHOLD }
enum class NotificationType { PURCHASE, ERROR, LOW_BALANCE, WITHDRAWAL_THRESHOLD, NETWORK_RETRY, MISSED_PURCHASES }

/**
* Room type converters
Expand Down Expand Up @@ -130,7 +130,11 @@ data class DcaPlanEntity(
val createdAt: Instant = Instant.now(),
val lastExecutedAt: Instant? = null,
val nextExecutionAt: Instant? = null,
val targetAmount: BigDecimal? = null
val targetAmount: BigDecimal? = null,
val networkRetryCount: Int = 0,
val nextNetworkRetryAt: Instant? = null,
val originalScheduledAt: Instant? = null,
val missedPurchaseCount: Int = 0
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import org.json.JSONObject

/**
* Structured arguments for notification templates.
* Stored as JSON in the `templateArgs` column text is rendered at display time
* Stored as JSON in the `templateArgs` column text is rendered at display time
* using the current locale, so language switches re-render old notifications.
*
* Numbers are stored as raw strings (BigDecimal.toPlainString()) and formatted
Expand All @@ -20,7 +20,9 @@ sealed class NotificationTemplateArgs {
val crypto: String,
val fiatAmount: String,
val fiat: String,
val price: String
val price: String,
val scheduledAtEpochMs: Long? = null,
val executedAtEpochMs: Long? = null
) : NotificationTemplateArgs() {
override fun toJson(): String = JSONObject().apply {
put(KEY_TYPE, TYPE_PURCHASE)
Expand All @@ -29,6 +31,8 @@ sealed class NotificationTemplateArgs {
put("fiatAmount", fiatAmount)
put("fiat", fiat)
put("price", price)
if (scheduledAtEpochMs != null) put("scheduledAtEpochMs", scheduledAtEpochMs)
if (executedAtEpochMs != null) put("executedAtEpochMs", executedAtEpochMs)
}.toString()
}

Expand All @@ -37,14 +41,18 @@ sealed class NotificationTemplateArgs {
val fiatAmount: String,
val fiat: String,
val crypto: String,
val price: String
val price: String,
val scheduledAtEpochMs: Long? = null,
val executedAtEpochMs: Long? = null
) : NotificationTemplateArgs() {
override fun toJson(): String = JSONObject().apply {
put(KEY_TYPE, TYPE_PURCHASE_PENDING)
put("fiatAmount", fiatAmount)
put("fiat", fiat)
put("crypto", crypto)
put("price", price)
if (scheduledAtEpochMs != null) put("scheduledAtEpochMs", scheduledAtEpochMs)
if (executedAtEpochMs != null) put("executedAtEpochMs", executedAtEpochMs)
}.toString()
}

Expand Down Expand Up @@ -88,7 +96,7 @@ sealed class NotificationTemplateArgs {
}.toString()
}

/** Target accumulation reached plan auto-disabled. */
/** Target accumulation reached plan auto-disabled. */
data class TargetReached(
val targetAmount: String,
val crypto: String
Expand Down Expand Up @@ -116,6 +124,42 @@ sealed class NotificationTemplateArgs {
}.toString()
}

/** Network error – purchase will be retried. */
data class NetworkRetry(
val crypto: String,
val exchangeName: String,
val errorMessage: String,
val nextRetryAtEpochMs: Long,
val attemptCount: Int,
val planId: Long
) : NotificationTemplateArgs() {
override fun toJson(): String = JSONObject().apply {
put(KEY_TYPE, TYPE_NETWORK_RETRY)
put("crypto", crypto)
put("exchangeName", exchangeName)
put("errorMessage", errorMessage)
put("nextRetryAtEpochMs", nextRetryAtEpochMs)
put("attemptCount", attemptCount)
put("planId", planId)
}.toString()
}

/** Missed purchases due to prolonged offline period. */
data class MissedPurchases(
val crypto: String,
val exchangeName: String,
val missedCount: Int,
val planId: Long
) : NotificationTemplateArgs() {
override fun toJson(): String = JSONObject().apply {
put(KEY_TYPE, TYPE_MISSED_PURCHASES)
put("crypto", crypto)
put("exchangeName", exchangeName)
put("missedCount", missedCount)
put("planId", planId)
}.toString()
}

companion object {
private const val KEY_TYPE = "type"
private const val TYPE_PURCHASE = "purchase"
Expand All @@ -125,6 +169,8 @@ sealed class NotificationTemplateArgs {
private const val TYPE_WITHDRAWAL_THRESHOLD = "withdrawal_threshold"
private const val TYPE_TARGET_REACHED = "target_reached"
private const val TYPE_BELOW_MINIMUM = "below_minimum"
private const val TYPE_NETWORK_RETRY = "network_retry"
private const val TYPE_MISSED_PURCHASES = "missed_purchases"

fun fromJson(json: String): NotificationTemplateArgs? = try {
val obj = JSONObject(json)
Expand All @@ -134,13 +180,17 @@ sealed class NotificationTemplateArgs {
crypto = obj.getString("crypto"),
fiatAmount = obj.getString("fiatAmount"),
fiat = obj.getString("fiat"),
price = obj.getString("price")
price = obj.getString("price"),
scheduledAtEpochMs = obj.optLong("scheduledAtEpochMs", 0L).takeIf { it > 0 },
executedAtEpochMs = obj.optLong("executedAtEpochMs", 0L).takeIf { it > 0 }
)
TYPE_PURCHASE_PENDING -> PurchasePending(
fiatAmount = obj.getString("fiatAmount"),
fiat = obj.getString("fiat"),
crypto = obj.getString("crypto"),
price = obj.getString("price")
price = obj.getString("price"),
scheduledAtEpochMs = obj.optLong("scheduledAtEpochMs", 0L).takeIf { it > 0 },
executedAtEpochMs = obj.optLong("executedAtEpochMs", 0L).takeIf { it > 0 }
)
TYPE_ERROR -> Error(
crypto = obj.getString("crypto"),
Expand All @@ -166,6 +216,20 @@ sealed class NotificationTemplateArgs {
fiat = obj.getString("fiat"),
minOrderSize = obj.getString("minOrderSize")
)
TYPE_MISSED_PURCHASES -> MissedPurchases(
crypto = obj.getString("crypto"),
exchangeName = obj.getString("exchangeName"),
missedCount = obj.getInt("missedCount"),
planId = obj.optLong("planId", 0)
)
TYPE_NETWORK_RETRY -> NetworkRetry(
crypto = obj.getString("crypto"),
exchangeName = obj.getString("exchangeName"),
errorMessage = obj.getString("errorMessage"),
nextRetryAtEpochMs = obj.getLong("nextRetryAtEpochMs"),
attemptCount = obj.optInt("attemptCount", 1),
planId = obj.optLong("planId", 0)
)
else -> null
}
} catch (_: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class UserPreferences @Inject constructor(

private val _appThemeFlow = MutableStateFlow(readAppTheme())

/** Observable theme state emits immediately when theme changes. */
/** Observable theme state emits immediately when theme changes. */
val appThemeFlow: StateFlow<AppTheme> = _appThemeFlow.asStateFlow()

private fun readAppTheme(): AppTheme {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ class MarketDataService @Inject constructor(
/**
* Get daily price history for a date range using CryptoCompare histoday endpoint.
* Free tier supports full historical data (up to 2000 data points per call).
* Used for historical backfill fetches data ending at [toDate] going back [limit] days.
* Used for historical backfill fetches data ending at [toDate] going back [limit] days.
* Returns list of (LocalDate, BigDecimal) pairs ordered by date ascending.
*/
suspend fun getDailyPriceHistoryRange(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.accbot.dca.domain.model
import java.time.Instant

/**
* Backup envelope the top-level structure of a backup file (always plaintext JSON).
* Backup envelope the top-level structure of a backup file (always plaintext JSON).
*/
data class BackupEnvelope(
val format: String = FORMAT_IDENTIFIER,
Expand All @@ -24,7 +24,7 @@ data class BackupEnvelope(
}

/**
* Backup payload the actual data after decryption/decompression.
* Backup payload the actual data after decryption/decompression.
*/
data class BackupPayload(
val plans: List<BackupPlan> = emptyList(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,19 @@ enum class Exchange(
supportedCryptos = listOf("BTC", "ETH", "SOL", "ADA"),
minOrderSize = mapOf("EUR" to BigDecimal("1"), "USD" to BigDecimal("1")),
sandboxSupport = SandboxSupport.FULL
)
);

companion object {
/** Binance LOT_SIZE step sizes per crypto (from /api/v3/exchangeInfo). */
val binanceLotStepSize = mapOf(
"BTC" to "0.00001",
"ETH" to "0.0001",
"BNB" to "0.001",
"SOL" to "0.001",
"ADA" to "0.1",
"DOT" to "0.01"
)
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ class CalculateChartDataUseCase @Inject constructor(
YearMonth.from(currentDate).let { it.year * 100 + it.monthValue }
}
if (pendingBucketKey != null && bucketKey != pendingBucketKey) {
// Bucket boundary crossed emit the pending snapshot
// Bucket boundary crossed emit the pending snapshot
pendingPrice?.let { pp ->
result.add(buildChartDataPoint(
pendingEpochDay, pendingCrypto, pendingInvested, pp
Expand All @@ -223,7 +223,7 @@ class CalculateChartDataUseCase @Inject constructor(
pendingPrice = price
pendingBucketKey = bucketKey
} else {
// Daily emission build point directly
// Daily emission build point directly
result.add(buildChartDataPoint(
epochDay, cumulativeCrypto, cumulativeInvested, price
))
Expand Down Expand Up @@ -356,7 +356,7 @@ class CalculateChartDataUseCase @Inject constructor(
YearMonth.from(currentDate).let { it.year * 100 + it.monthValue }
}
if (pendingBucketKey != null && bucketKey != pendingBucketKey && hasPendingData) {
// Bucket boundary crossed emit previous snapshot
// Bucket boundary crossed emit previous snapshot
result.add(buildAggregatePoint(pendingEpochDay, pendingValue, pendingInvested))
}
pendingEpochDay = epochDay
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class ImportTradeHistoryUseCase @Inject constructor(
sinceDate: Instant? = null
): Flow<ApiImportProgress> = flow {
try {
// Fetch all pages from sinceDate if provided, otherwise from the beginning.
// Fetch all pages from sinceDate if provided, otherwise from the beginning.
// Deduplication by exchangeOrderId prevents duplicates, so there's no need
// for a timestamp cursor which can skip historical trades when the only
// existing transactions are from auto-buy (not from a prior API import).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import javax.inject.Inject

/**
* Orchestrates two-phase daily price sync:
* 1. Forward sync (every sync): CoinGecko fills the small gap between latest cached date and today
* 2. Historical backfill (one-time per pair): CryptoCompare fetches older data in ≤2000-day chunks backwards
* 1. Forward sync (every sync): CoinGecko fills the small gap between latest cached date and today
* 2. Historical backfill (one-time per pair): CryptoCompare fetches older data in ≤2000-day chunks backwards
*
* Historical prices are immutable once fetched, they never need re-fetching.
* Historical prices are immutable once fetched, they never need re-fetching.
* After the one-time backfill, subsequent syncs only run phase 1 (0-2 API calls total).
*/
class SyncDailyPricesUseCase @Inject constructor(
Expand Down Expand Up @@ -62,7 +62,7 @@ class SyncDailyPricesUseCase @Inject constructor(
val latestDate = latestDay?.let { LocalDate.ofEpochDay(it) }

if (latestDate == null) {
// Brand new pair bootstrap with last 365 days via CoinGecko
// Brand new pair bootstrap with last 365 days via CoinGecko
Log.d(TAG, "[$crypto/$fiat] Bootstrap: fetching last 365 days")
val prices = marketDataService.getDailyPriceHistory(crypto, fiat, 365)
if (prices != null && prices.isNotEmpty()) {
Expand Down
Loading