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
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ jobs:
}
EOF

# Robolectric downloads an android-all jar per SDK level (~300 MB) the first time a test
# runs; setup-gradle doesn't cache it, so it was fetched again on every run.
- name: Cache Robolectric SDK jars
uses: actions/cache@v4
with:
path: ~/.m2/repository/org/robolectric
key: robolectric-${{ hashFiles('gradle/libs.versions.toml') }}
restore-keys: robolectric-

- name: Build
run: ./gradlew :app:assembleDebug

Expand All @@ -94,13 +103,37 @@ jobs:
steps:
- uses: actions/checkout@v4

# The project and its tests are set up with Xcode 27 / iOS 27 (the widget tests target
# iOS 27). Fails fast here if the runner image doesn't ship it.
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '27'

- uses: actions/setup-java@v4
with:
distribution: zulu
java-version: 21

- uses: gradle/actions/setup-gradle@v4

# The Kotlin/Native toolchain and its dependencies (~1 GB) live in ~/.konan, outside
# Gradle's cache, and were downloaded again on every run. Keyed on the versions file,
# which pins the Kotlin version.
- name: Cache Kotlin/Native
uses: actions/cache@v4
with:
path: ~/.konan
key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }}
restore-keys: konan-${{ runner.os }}-

# Swift packages (Google Mobile Ads, Yandex, AppMetrica, ...) resolved by xcodebuild.
- name: Cache Swift packages
uses: actions/cache@v4
with:
path: ~/spm-packages
key: spm-${{ hashFiles('iosApp/MetanMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-

# secrets.properties is gitignored, but the Secrets Gradle plugin fails without it.
# Real ad unit ids fall back to test ids in core:ui, so placeholders are enough.
- name: Create stub secrets.properties
Expand All @@ -114,3 +147,33 @@ jobs:

- name: Compile shared code for iOS
run: ./gradlew :composeApp:compileKotlinIosSimulatorArm64

# Kotlin tests of the widget modules on the iOS simulator (the JVM run is in the Android job).
- name: Widget Kotlin tests (iOS)
run: ./gradlew :widget:core:iosSimulatorArm64Test :widget:nearest-station:iosSimulatorArm64Test

# The Swift widget's tests. The test target has no host app, so this doesn't build the
# app (nor its Kotlin framework) - only the widget's sources and the tests.
- name: Widget Swift tests
run: |
UDID=$(xcrun simctl list devices available -j | jq -r '
[.devices | to_entries[] | select(.key | test("iOS-27"))
| .value[] | select(.name | startswith("iPhone"))][0].udid')
if [ -z "$UDID" ] || [ "$UDID" = "null" ]; then
echo "No iOS 27 iPhone simulator on this runner"; xcrun simctl list runtimes; exit 1
fi
xcodebuild test \
-project iosApp/MetanMobile.xcodeproj \
-scheme MetanMobileWidgetTests \
-destination "id=$UDID" \
-clonedSourcePackagesDirPath ~/spm-packages \
-resultBundlePath build/MetanMobileWidgetTests.xcresult

- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: ios-reports
path: |
build/MetanMobileWidgetTests.xcresult
**/build/reports/
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ dependencies {
implementation(projects.core.network)
implementation(projects.core.networkClient)
implementation(projects.sync.work)
implementation(projects.widget.core)
implementation(projects.widget.nearestStation)

implementation(projects.feature.cabinet)
implementation(projects.feature.favorites)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.ngapp.metanmobile.core.data.di.locationDataModule
import com.ngapp.metanmobile.core.data.di.locationModule
import com.ngapp.metanmobile.core.data.di.syncDataModule
import com.ngapp.metanmobile.core.data.di.userDataModule
import com.ngapp.metanmobile.core.data.di.widgetDataModule
import com.ngapp.metanmobile.core.database.di.databaseModule
import com.ngapp.metanmobile.core.datastore.di.userPreferencesDataStoreModule
import com.ngapp.metanmobile.core.network.client.di.networkClientModule
Expand All @@ -17,6 +18,9 @@ import com.ngapp.metanmobile.composeapp.configureImageLoader
import com.ngapp.metanmobile.composeapp.di.mainModule
import com.ngapp.metanmobile.sync.di.syncModule
import com.ngapp.metanmobile.sync.initializers.Sync
import com.ngapp.metanmobile.widget.core.di.widgetCoreModule
import com.ngapp.metanmobile.widget.core.initializers.Widgets
import com.ngapp.metanmobile.widget.nearest.station.di.nearestStationWidgetModule
import com.ngapp.metanmobile.feature.onboarding.di.featureOnboardingModule
import com.ngapp.metanmobile.feature.news.di.featureNewsModule
import com.ngapp.metanmobile.feature.favorites.di.featureFavoritesModule
Expand Down Expand Up @@ -55,6 +59,7 @@ class MetanMobileApplication : Application() {
locationModule(),
locationDataModule,
syncDataModule,
widgetDataModule,
syncModule(),
mainModule,
uiModule,
Expand All @@ -71,8 +76,11 @@ class MetanMobileApplication : Application() {
featureContactsModule,
featureFaqModule,
featurePrivacyPolicyModule,
widgetCoreModule,
nearestStationWidgetModule,
)
}
Sync.initialize(context = this)
Widgets.initialize()
}
}
5 changes: 5 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ kotlin {
// the framework's generated Objective-C header so MobileAdsBridge.swift can actually
// implement it - same "export needs an api dependency" requirement as above.
export(projects.core.ui)
// widget:core's WidgetReloader is implemented in Swift (WidgetCenter is Swift-only),
// so it has to be visible in the framework header too.
export(projects.widget.core)
}
}

Expand Down Expand Up @@ -77,6 +80,8 @@ kotlin {
// for Android's own WorkManager wiring via the :app module's own dependency on it.
iosMain.dependencies {
implementation(projects.sync.work)
api(projects.widget.core)
implementation(projects.widget.nearestStation)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,20 @@ fun MetanMobileNavHost(
// ?.let { appState.navigateToDeepLink(it) } }, just fed by DeepLinkHolder instead of an
// Android-only Intent so both platform entry points can push into the same place.
val pendingDeepLink by DeepLinkHolder.pendingUrl.collectAsStateWithLifecycle()
LaunchedEffect(pendingDeepLink) {
val entry by navController.currentBackStackEntryAsState()
// On a cold start (the app opened straight from a link or a home-screen widget) the link
// arrives before the NavHost below is mounted — it waits for onboarding state to load — and
// navigating then crashes with "You must call setGraph() before calling getGraph()". Keep the
// link pending until the graph exists, i.e. the first back stack entry is there.
val isGraphReady = entry != null
LaunchedEffect(pendingDeepLink, isGraphReady) {
if (!isGraphReady) return@LaunchedEffect
pendingDeepLink?.let {
appState.navigateToDeepLink(it)
DeepLinkHolder.consume()
}
}

val entry by navController.currentBackStackEntryAsState()
val isOnboarding = entry?.destination?.hasRoute<OnboardingNavigationRoute>() == true
val selectedDestination = TopLevelDestination.entries.firstOrNull { entry?.destination?.route == it.route }
// Top-level screens hide the bar while their station-detail bottom sheet is expanded. The
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,22 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.context.startKoin
import org.koin.dsl.module
import com.ngapp.metanmobile.widget.nearest.station.di.nearestStationWidgetModule
import com.ngapp.metanmobile.widget.core.di.widgetCoreModule
import com.ngapp.metanmobile.widget.core.WidgetReloader
import com.ngapp.metanmobile.widget.core.WidgetPublisher
import com.ngapp.metanmobile.core.data.di.widgetDataModule

/**
* Initializes the shared graph before the SwiftUI host creates Compose content.
*
* @param nativeAdsBridge Swift's real GoogleMobileAds/UserMessagingPlatform implementation
* (MobileAdsBridge.swift) - see core:ui's NativeAdsBridge.kt for why this can't just be
* resolved through Koin like everything else here.
* @param widgetReloader Swift's WidgetCenter call (WidgetReloaderBridge.swift), used to redraw
* the home-screen widgets after their data in the App Group changed.
*/
fun initSharedKoin(nativeAdsBridge: NativeAdsBridge) {
fun initSharedKoin(nativeAdsBridge: NativeAdsBridge, widgetReloader: WidgetReloader) {
registerNativeAdsBridge(nativeAdsBridge)
configureImageLoader()
val koinApp = startKoin {
Expand Down Expand Up @@ -86,12 +93,19 @@ fun initSharedKoin(nativeAdsBridge: NativeAdsBridge) {
featureContactsModule,
featureFaqModule,
featurePrivacyPolicyModule,
module { single<WidgetReloader> { widgetReloader } },
widgetDataModule,
widgetCoreModule,
nearestStationWidgetModule,
)
}
// Mirrors Android's Sync.initialize(context) call right after startKoin{} in
// MetanMobileApplication.onCreate() — same "sync once at app launch" behavior, just reached
// through the SyncManager Koin already built instead of going straight to WorkManager.
koinApp.koin.get<SyncManager>().requestSync()
// Mirrors Android's Widgets.initialize(): keeps the widgets' App Group data in step with the
// local database for as long as the app runs (foreground or a background sync).
koinApp.koin.get<WidgetPublisher>().start(CoroutineScope(SupervisorJob() + Dispatchers.Default))
// BGTaskScheduler.registerForTaskWithIdentifier must run before the end of App.init() (this
// SwiftUI app has no AppDelegate, so init() is the equivalent of applicationDidFinishLaunching)
// — initSharedKoin() is called from exactly there (MetanMobileApp.swift), so this is the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package com.ngapp.metanmobile.core.data.test.repository.widget

import app.cash.turbine.test
import com.ngapp.metanmobile.core.data.Synchronizer
import com.ngapp.metanmobile.core.data.repository.location.LocationsRepository
import com.ngapp.metanmobile.core.data.repository.price.PricesRepository
import com.ngapp.metanmobile.core.data.repository.station.StationResourceQuery
import com.ngapp.metanmobile.core.data.repository.station.StationResourcesWithFavoritesRepository
import com.ngapp.metanmobile.core.data.repository.widget.CompositeWidgetDataRepository
import com.ngapp.metanmobile.core.model.location.LocationResource
import com.ngapp.metanmobile.core.model.price.PriceResource
import com.ngapp.metanmobile.core.model.station.UserStationResource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

/**
* Unit tests for [CompositeWidgetDataRepository]: the widgets' snapshot of local data, built
* from the same repositories the app's screens use and never from the network.
*/
class CompositeWidgetDataRepositoryTest {

private val stationsRepository = FakeUserStationsRepository()
private val pricesRepository = FakePricesRepository()
private val locationsRepository = FakeLocationsRepository()

private val repository = CompositeWidgetDataRepository(
stationsRepository = stationsRepository,
pricesRepository = pricesRepository,
locationsRepository = locationsRepository,
)

@Test
fun `observeWidgetData combines the price, the location and the stations`() = runTest {
val price = PriceResource.init().copy(content = "1.16")
val location = LocationResource(id = 1, time = 0L, latitude = 53.9, longitude = 27.5)
val station = UserStationResource.init().copy(code = "a", distanceBetween = 2.4)
pricesRepository.price.value = price
locationsRepository.location.value = location
stationsRepository.stations.value = listOf(station)

val data = repository.observeWidgetData().first()

assertEquals(price, data.cngPrice)
assertEquals(location, data.location)
assertEquals(listOf(station), data.stations)
}

@Test
fun `observeWidgetData reports an unknown location and no price as nulls`() = runTest {
val data = repository.observeWidgetData().first()

assertNull(data.cngPrice)
assertNull(data.location)
assertEquals(emptyList(), data.stations)
}

@Test
fun `observeWidgetData emits again when local data changes`() = runTest {
repository.observeWidgetData().test {
assertNull(awaitItem().cngPrice)

pricesRepository.price.value = PriceResource.init().copy(content = "1.20")

assertEquals("1.20", awaitItem().cngPrice?.content)
cancelAndIgnoreRemainingEvents()
}
}

@Test
fun `observeWidgetData skips updates that change nothing`() = runTest {
val station = UserStationResource.init().copy(code = "a")
stationsRepository.stations.value = listOf(station)

repository.observeWidgetData().test {
awaitItem()

// A new list with equal content, as a sync re-writing the same rows produces.
stationsRepository.stations.value = listOf(station.copy())

expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}
}

/** Tiny hand-written fakes, in keeping with the project's existing testing convention. */
private class FakeUserStationsRepository : StationResourcesWithFavoritesRepository {
val stations = MutableStateFlow<List<UserStationResource>>(emptyList())

override fun observeAll(query: StationResourceQuery): Flow<List<UserStationResource>> = stations

override fun observeAllFavorites(query: StationResourceQuery): Flow<List<UserStationResource>> =
stations.map { list -> list.filter { it.isFavorite } }
}

private class FakePricesRepository : PricesRepository {
val price = MutableStateFlow<PriceResource?>(null)

override fun getFuelPrice(): Flow<PriceResource?> = price

override suspend fun syncWith(synchronizer: Synchronizer) = true
}

private class FakeLocationsRepository : LocationsRepository {
val location = MutableStateFlow<LocationResource?>(null)

override fun getLocationResources(): Flow<List<LocationResource>> = location.map(::listOfNotNull)

override fun getLocationResource(): Flow<LocationResource?> = location

override suspend fun updateLocation(locationPermissionGranted: Boolean) = Unit
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package com.ngapp.metanmobile.core.data.di

import com.ngapp.metanmobile.core.data.repository.widget.CompositeWidgetDataRepository
import com.ngapp.metanmobile.core.data.repository.widget.WidgetDataRepository
import org.koin.core.module.Module
import org.koin.dsl.module

val widgetDataModule: Module = module {
single<WidgetDataRepository> { CompositeWidgetDataRepository(get(), get(), get()) }
}
Loading
Loading