Skip to content

Commit 7c93e5d

Browse files
authored
refactor(funding): decouple from billing client (#10560)
2 parents edd3156 + a0e66d5 commit 7c93e5d

34 files changed

Lines changed: 1423 additions & 301 deletions

app-common/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dependencies {
2121
implementation(projects.legacy.core)
2222
implementation(projects.legacy.ui.base)
2323
implementation(projects.core.android.account)
24+
implementation(projects.core.android.common)
2425

2526
implementation(projects.core.logging.api)
2627
implementation(projects.core.logging.implComposite)
@@ -64,6 +65,8 @@ dependencies {
6465

6566
testImplementation(projects.feature.account.fake)
6667
testImplementation(projects.core.testing)
68+
testImplementation(projects.core.android.testing)
69+
testImplementation(projects.core.logging.testing)
6770
}
6871

6972
codeCoverage {

app-common/src/main/kotlin/net/thunderbird/app/common/AppCommonModule.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import com.fsck.k9.legacyCommonAppModules
44
import com.fsck.k9.legacyCoreModules
55
import com.fsck.k9.legacyUiModules
66
import net.thunderbird.app.common.account.appCommonAccountModule
7+
import net.thunderbird.app.common.activity.DefaultActivityProvider
78
import net.thunderbird.app.common.appConfig.AndroidPlatformConfigProvider
89
import net.thunderbird.app.common.core.appCommonCoreModule
910
import net.thunderbird.app.common.feature.appCommonFeatureModule
1011
import net.thunderbird.app.common.startup.appCommonStartupModule
12+
import net.thunderbird.core.android.common.activity.ActivityProvider
1113
import net.thunderbird.core.common.appConfig.PlatformConfigProvider
14+
import org.koin.android.ext.koin.androidApplication
1215
import org.koin.core.module.Module
1316
import org.koin.dsl.module
1417

@@ -25,4 +28,10 @@ val appCommonModule: Module = module {
2528
)
2629

2730
single<PlatformConfigProvider> { AndroidPlatformConfigProvider() }
31+
single<ActivityProvider>(createdAtStart = true) {
32+
DefaultActivityProvider(
33+
application = androidApplication(),
34+
logger = get(),
35+
)
36+
}
2837
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package net.thunderbird.app.common.activity
2+
3+
import android.app.Activity
4+
import android.app.Application
5+
import android.os.Bundle
6+
import androidx.lifecycle.DefaultLifecycleObserver
7+
import androidx.lifecycle.LifecycleOwner
8+
import androidx.lifecycle.ProcessLifecycleOwner
9+
import java.lang.ref.WeakReference
10+
import net.thunderbird.core.android.common.activity.ActivityProvider
11+
import net.thunderbird.core.logging.Logger
12+
13+
private const val TAG = "DefaultActivityProvider"
14+
15+
/**
16+
* ActivityProvider implementation that tracks the current resumed activity and whether the app is in the foreground.
17+
*/
18+
class DefaultActivityProvider(
19+
application: Application,
20+
private val logger: Logger,
21+
) : Application.ActivityLifecycleCallbacks, ActivityProvider, DefaultLifecycleObserver {
22+
@Volatile
23+
private var lastResumedRef: WeakReference<Activity>? = null
24+
25+
@Volatile
26+
private var inForeground: Boolean = false
27+
28+
override fun getCurrent(): Activity? = if (inForeground) lastResumedRef?.get() else null
29+
30+
init {
31+
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
32+
application.registerActivityLifecycleCallbacks(this)
33+
}
34+
35+
// ProcessLifecycleOwner callbacks
36+
override fun onStart(owner: LifecycleOwner) {
37+
logger.debug(TAG) { "App in foreground" }
38+
inForeground = true
39+
}
40+
41+
override fun onStop(owner: LifecycleOwner) {
42+
logger.debug(TAG) { "App in background" }
43+
inForeground = false
44+
lastResumedRef = null
45+
}
46+
47+
// ActivityLifecycleCallbacks
48+
override fun onActivityResumed(activity: Activity) {
49+
logger.debug(TAG) { "onActivityResumed: setting activity to ${activity::class.java.simpleName}" }
50+
lastResumedRef = WeakReference(activity)
51+
}
52+
53+
override fun onActivityPaused(activity: Activity) {
54+
if (lastResumedRef?.get() === activity) {
55+
logger.debug(TAG) { "onActivityPaused: clearing current activity ${activity::class.java.simpleName}" }
56+
lastResumedRef = null
57+
}
58+
}
59+
60+
override fun onActivityDestroyed(activity: Activity) {
61+
if (lastResumedRef?.get() === activity) {
62+
logger.debug(TAG) { "onActivityDestroyed: clearing current activity ${activity::class.java.simpleName}" }
63+
lastResumedRef = null
64+
}
65+
}
66+
67+
override fun onActivityCreated(a: Activity, b: Bundle?) = Unit
68+
override fun onActivityStarted(a: Activity) = Unit
69+
override fun onActivityStopped(a: Activity) = Unit
70+
override fun onActivitySaveInstanceState(a: Activity, outState: Bundle) = Unit
71+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package net.thunderbird.app.common.activity
2+
3+
import android.app.Activity
4+
import androidx.activity.ComponentActivity
5+
import androidx.lifecycle.ProcessLifecycleOwner
6+
import assertk.assertThat
7+
import assertk.assertions.contains
8+
import assertk.assertions.isEqualTo
9+
import assertk.assertions.isNull
10+
import net.thunderbird.core.android.testing.RobolectricTest
11+
import net.thunderbird.core.logging.LogEvent
12+
import net.thunderbird.core.logging.LogLevel
13+
import net.thunderbird.core.logging.testing.TestLogger
14+
import org.junit.Test
15+
import org.robolectric.Robolectric
16+
import org.robolectric.RuntimeEnvironment
17+
18+
class DefaultActivityProviderTest : RobolectricTest() {
19+
20+
private val application = RuntimeEnvironment.getApplication()
21+
private val logger = TestLogger()
22+
23+
@Test
24+
fun `getCurrent returns null initially`() {
25+
// Arrange
26+
val testSubject = DefaultActivityProvider(
27+
application = application,
28+
logger = logger,
29+
)
30+
31+
// Assert
32+
assertThat(testSubject.getCurrent()).isNull()
33+
}
34+
35+
@Test
36+
fun `tracks current activity when in foreground and clears when backgrounded`() {
37+
// Arrange
38+
val testSubject = DefaultActivityProvider(
39+
application = application,
40+
logger = logger,
41+
)
42+
43+
val controller = Robolectric.buildActivity(Activity::class.java)
44+
45+
// Act: bring app/activity to foreground
46+
testSubject.onStart(ProcessLifecycleOwner.get())
47+
val activity = controller.create().start().resume().get()
48+
49+
// Assert: provider returns the resumed activity
50+
assertThat(testSubject.getCurrent()).isEqualTo(activity)
51+
52+
// Act: pause activity (still foreground process-wise)
53+
controller.pause()
54+
55+
// Assert: returns null when activity is paused
56+
assertThat(testSubject.getCurrent()).isNull()
57+
58+
// Act: resume activity
59+
controller.resume()
60+
assertThat(testSubject.getCurrent()).isEqualTo(activity)
61+
62+
// Act: stop activity (app goes to background)
63+
controller.stop()
64+
// Manually move the process to background
65+
testSubject.onStop(ProcessLifecycleOwner.get())
66+
67+
// Assert: returns null when app is in background
68+
assertThat(testSubject.getCurrent()).isNull()
69+
70+
// Act: destroy activity
71+
controller.destroy()
72+
assertThat(testSubject.getCurrent()).isNull()
73+
}
74+
75+
@Test
76+
fun `tracks multiple activities and logs transitions`() {
77+
// Arrange
78+
val testSubject = DefaultActivityProvider(
79+
application = application,
80+
logger = logger,
81+
)
82+
val controller1 = Robolectric.buildActivity(Activity::class.java)
83+
val controller2 = Robolectric.buildActivity(ComponentActivity::class.java)
84+
85+
// Act: Start first activity and move process to foreground
86+
testSubject.onStart(ProcessLifecycleOwner.get())
87+
val activity1 = controller1.create().start().resume().get()
88+
89+
// Assert: First activity is current
90+
assertThat(testSubject.getCurrent()).isEqualTo(activity1)
91+
assertThat(logger.events).contains(
92+
LogEvent(
93+
level = LogLevel.DEBUG,
94+
tag = "DefaultActivityProvider",
95+
message = "onActivityResumed: setting activity to Activity",
96+
timestamp = TestLogger.TIMESTAMP,
97+
),
98+
)
99+
100+
// Act: Start second activity
101+
val activity2 = controller2.create().start().resume().get()
102+
103+
// Assert: Second activity is now current
104+
assertThat(testSubject.getCurrent()).isEqualTo(activity2)
105+
assertThat(logger.events).contains(
106+
LogEvent(
107+
level = LogLevel.DEBUG,
108+
tag = "DefaultActivityProvider",
109+
message = "onActivityResumed: setting activity to ComponentActivity",
110+
timestamp = TestLogger.TIMESTAMP,
111+
),
112+
)
113+
114+
// Act: Destroy second activity
115+
controller2.pause().stop().destroy()
116+
117+
// Assert: Should clear current activity as it was the last resumed
118+
assertThat(testSubject.getCurrent()).isNull()
119+
assertThat(logger.events).contains(
120+
LogEvent(
121+
level = LogLevel.DEBUG,
122+
tag = "DefaultActivityProvider",
123+
message = "onActivityPaused: clearing current activity ComponentActivity",
124+
timestamp = TestLogger.TIMESTAMP,
125+
),
126+
)
127+
}
128+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package net.thunderbird.core.android.common.activity
2+
3+
import android.app.Activity
4+
5+
/**
6+
* Interface for providing the current [Activity].
7+
*/
8+
fun interface ActivityProvider {
9+
10+
/**
11+
* Returns the current [Activity].
12+
*
13+
* @return The current activity, or `null` if no activity is available.
14+
*/
15+
fun getCurrent(): Activity?
16+
}

feature/funding/googleplay/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies {
1717
api(projects.feature.funding.api)
1818

1919
implementation(projects.core.common)
20+
implementation(projects.core.android.common)
2021
implementation(projects.core.outcome)
2122
implementation(projects.core.logging.api)
2223
implementation(projects.core.ui.compose.designsystem)
@@ -26,6 +27,7 @@ dependencies {
2627
implementation(libs.android.material)
2728

2829
testImplementation(projects.core.testing)
30+
testImplementation(projects.core.logging.testing)
2931
testImplementation(projects.core.ui.compose.testing)
3032

3133
testImplementation(libs.androidx.lifecycle.runtime.testing)
Lines changed: 6 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
package net.thunderbird.feature.funding
22

3-
import com.android.billingclient.api.ProductDetails
43
import kotlin.time.ExperimentalTime
5-
import net.thunderbird.core.common.cache.Cache
6-
import net.thunderbird.core.common.cache.InMemoryCache
74
import net.thunderbird.feature.funding.api.FundingManager
85
import net.thunderbird.feature.funding.api.FundingNavigation
96
import net.thunderbird.feature.funding.googleplay.GooglePlayFundingManager
107
import net.thunderbird.feature.funding.googleplay.GooglePlayFundingNavigation
11-
import net.thunderbird.feature.funding.googleplay.data.FundingDataContract
12-
import net.thunderbird.feature.funding.googleplay.data.GoogleBillingClient
13-
import net.thunderbird.feature.funding.googleplay.data.mapper.BillingResultMapper
14-
import net.thunderbird.feature.funding.googleplay.data.mapper.ProductDetailsMapper
15-
import net.thunderbird.feature.funding.googleplay.data.remote.GoogleBillingClientProvider
16-
import net.thunderbird.feature.funding.googleplay.data.remote.GoogleBillingPurchaseHandler
17-
import net.thunderbird.feature.funding.googleplay.domain.BillingManager
8+
import net.thunderbird.feature.funding.googleplay.data.fundingDataModule
189
import net.thunderbird.feature.funding.googleplay.domain.ContributionIdProvider
1910
import net.thunderbird.feature.funding.googleplay.domain.FundingDomainContract
2011
import net.thunderbird.feature.funding.googleplay.domain.usecase.GetAvailableContributions
@@ -28,6 +19,8 @@ import org.koin.core.module.dsl.viewModel
2819
import org.koin.dsl.module
2920

3021
val featureFundingModule = module {
22+
includes(fundingDataModule)
23+
3124
single<FundingReminderContract.Dialog> {
3225
FundingReminderDialog()
3326
}
@@ -63,64 +56,21 @@ val featureFundingModule = module {
6356

6457
single<FundingNavigation> { GooglePlayFundingNavigation() }
6558

66-
single<FundingDataContract.Mapper.Product> {
67-
ProductDetailsMapper()
68-
}
69-
70-
single<FundingDataContract.Mapper.BillingResult> {
71-
BillingResultMapper()
72-
}
73-
74-
single<FundingDataContract.Remote.GoogleBillingClientProvider> {
75-
GoogleBillingClientProvider(
76-
context = get(),
77-
)
78-
}
79-
80-
single<Cache<String, ProductDetails>> {
81-
InMemoryCache()
82-
}
83-
84-
single<FundingDataContract.Remote.GoogleBillingPurchaseHandler> {
85-
GoogleBillingPurchaseHandler(
86-
productCache = get(),
87-
productMapper = get(),
88-
logger = get(),
89-
)
90-
}
91-
92-
single<FundingDataContract.BillingClient> {
93-
GoogleBillingClient(
94-
clientProvider = get(),
95-
productMapper = get(),
96-
resultMapper = get(),
97-
productCache = get(),
98-
purchaseHandler = get(),
99-
logger = get(),
100-
)
101-
}
102-
10359
single<FundingDomainContract.ContributionIdProvider> {
10460
ContributionIdProvider()
10561
}
10662

107-
single<FundingDomainContract.BillingManager> {
108-
BillingManager(
109-
billingClient = get(),
110-
contributionIdProvider = get(),
111-
)
112-
}
113-
11463
single<FundingDomainContract.UseCase.GetAvailableContributions> {
11564
GetAvailableContributions(
116-
billingManager = get(),
65+
repository = get(),
66+
contributionIdProvider = get(),
11767
)
11868
}
11969

12070
viewModel {
12171
ContributionViewModel(
122-
billingManager = get(),
12372
getAvailableContributions = get(),
73+
repository = get(),
12474
)
12575
}
12676
}

0 commit comments

Comments
 (0)