diff --git a/app/src/main/java/foundation/e/apps/data/fused/FusedAPIRepository.kt b/app/src/main/java/foundation/e/apps/data/fused/FusedAPIRepository.kt index 220bf137b295ecd4272e3e9e2955f9c1330836b4..224fc833d8f678f60d48e7dc0ab6ea7985bc41a8 100644 --- a/app/src/main/java/foundation/e/apps/data/fused/FusedAPIRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/fused/FusedAPIRepository.kt @@ -38,7 +38,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class FusedAPIRepository @Inject constructor(private val fusedAPIImpl: FusedAPIImpl) { +class FusedAPIRepository @Inject constructor(private val fusedAPIImpl: FusedApi) { var streamBundle = StreamBundle() private set @@ -82,8 +82,8 @@ class FusedAPIRepository @Inject constructor(private val fusedAPIImpl: FusedAPII return fusedAPIImpl.getHomeScreenData(authData) } - fun isFusedHomesEmpty(fusedHomes: List): Boolean { - return fusedAPIImpl.isFusedHomesEmpty(fusedHomes) + fun isHomesEmpty(fusedHomes: List): Boolean { + return fusedAPIImpl.isHomesEmpty(fusedHomes) } fun getApplicationCategoryPreference(): List { diff --git a/app/src/main/java/foundation/e/apps/data/fused/FusedApi.kt b/app/src/main/java/foundation/e/apps/data/fused/FusedApi.kt new file mode 100644 index 0000000000000000000000000000000000000000..759786cfcf41c0a89894357c52b7a2ce92e3c522 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/fused/FusedApi.kt @@ -0,0 +1,181 @@ +package foundation.e.apps.data.fused + +import androidx.lifecycle.LiveData +import com.aurora.gplayapi.SearchSuggestEntry +import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster +import foundation.e.apps.data.ResultSupreme +import foundation.e.apps.data.cleanapk.data.download.Download +import foundation.e.apps.data.enums.FilterLevel +import foundation.e.apps.data.enums.Origin +import foundation.e.apps.data.enums.ResultStatus +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.fused.data.FusedApp +import foundation.e.apps.data.fused.data.FusedCategory +import foundation.e.apps.data.fused.data.FusedHome +import foundation.e.apps.data.fused.utils.CategoryType +import foundation.e.apps.data.fusedDownload.models.FusedDownload +import retrofit2.Response + +interface FusedApi { + companion object { + const val APP_TYPE_ANY = "any" + const val APP_TYPE_OPEN = "open" + const val APP_TYPE_PWA = "pwa" + } + + /** + * Check if list in all the FusedHome is empty. + * If any list is not empty, send false. + * Else (if all lists are empty) send true. + */ + fun isHomesEmpty(fusedHomes: List): Boolean + fun getApplicationCategoryPreference(): List + + suspend fun getHomeScreenData( + authData: AuthData, + ): LiveData>> + + /* + * Return three elements from the function. + * - List : List of categories. + * - String : String of application type - By default it is the value in preferences. + * In case there is any failure, for a specific type in handleAllSourcesCategories(), + * the string value is of that type. + * - ResultStatus : ResultStatus - by default is ResultStatus.OK. But in case there is a failure in + * any application category type, then it takes value of that failure. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 + */ + suspend fun getCategoriesList( + type: CategoryType, + ): Triple, String, ResultStatus> + + /** + * Fetches search results from cleanAPK and GPlay servers and returns them + * @param query Query + * @param authData [AuthData] + * @return A livedata Pair of list of non-nullable [FusedApp] and + * a Boolean signifying if more search results are being loaded. + * Observe this livedata to display new apps as they are fetched from the network. + */ + fun getSearchResults( + query: String, + authData: AuthData + ): LiveData, Boolean>>> + + suspend fun getSearchSuggestions(query: String): List + + suspend fun getOnDemandModule( + packageName: String, + moduleName: String, + versionCode: Int, + offerType: Int + ): String? + + suspend fun updateFusedDownloadWithDownloadingInfo( + origin: Origin, + fusedDownload: FusedDownload + ) + + suspend fun getOSSDownloadInfo(id: String, version: String?): Response + + suspend fun getPWAApps(category: String): ResultSupreme> + + suspend fun getOpenSourceApps(category: String): ResultSupreme> + + suspend fun getNextStreamBundle( + homeUrl: String, + currentStreamBundle: StreamBundle, + ): ResultSupreme + + suspend fun getAdjustedFirstCluster( + streamBundle: StreamBundle, + pointer: Int = 0, + ): ResultSupreme + + suspend fun getNextStreamCluster( + currentStreamCluster: StreamCluster, + ): ResultSupreme + + suspend fun getPlayStoreApps( + browseUrl: String, + ): ResultSupreme> + + /* + * Function to search cleanapk using package name. + * Will be used to handle f-droid deeplink. + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5509 + */ + suspend fun getCleanapkAppDetails(packageName: String): Pair + + suspend fun getApplicationDetails( + packageNameList: List, + authData: AuthData, + origin: Origin + ): Pair, ResultStatus> + + /** + * Filter out apps which are restricted, whose details cannot be fetched. + * If an app is restricted, we do try to fetch the app details inside a + * try-catch block. If that fails, we remove the app, else we keep it even + * if it is restricted. + * + * Popular example: "com.skype.m2" + * + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5174 + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] + */ + suspend fun filterRestrictedGPlayApps( + authData: AuthData, + appList: List, + ): ResultSupreme> + + /** + * Get different filter levels. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5720 + */ + suspend fun getAppFilterLevel(fusedApp: FusedApp, authData: AuthData?): FilterLevel + + /* + * Similar to above method but uses Aurora OSS data class "App". + */ + suspend fun getAppFilterLevel(app: App, authData: AuthData): FilterLevel + + suspend fun getApplicationDetails( + id: String, + packageName: String, + authData: AuthData, + origin: Origin + ): Pair + + /** + * Get fused app installation status. + * Applicable for both native apps and PWAs. + * + * Recommended to use this instead of [PkgManagerModule.getPackageStatus]. + */ + fun getFusedAppInstallationStatus(fusedApp: FusedApp): Status + + /** + * @return true, if any change is found, otherwise false + */ + fun isHomeDataUpdated( + newHomeData: List, + oldHomeData: List + ): Boolean + + /** + * @return returns true if there is changes in data, otherwise false + */ + fun isAnyFusedAppUpdated( + newFusedApps: List, + oldFusedApps: List + ): Boolean + + fun isAnyAppInstallStatusChanged(currentList: List): Boolean + fun isOpenSourceSelected(): Boolean +} diff --git a/app/src/main/java/foundation/e/apps/data/fused/FusedAPIImpl.kt b/app/src/main/java/foundation/e/apps/data/fused/FusedApiImpl.kt similarity index 93% rename from app/src/main/java/foundation/e/apps/data/fused/FusedAPIImpl.kt rename to app/src/main/java/foundation/e/apps/data/fused/FusedApiImpl.kt index db60c4d7ad70309b34b0e3d442901afa952c84c8..38a1bb3b2d45bb3378d124543b3d8c94c3ae2697 100644 --- a/app/src/main/java/foundation/e/apps/data/fused/FusedAPIImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/fused/FusedApiImpl.kt @@ -52,6 +52,9 @@ import foundation.e.apps.data.enums.Status import foundation.e.apps.data.enums.Type import foundation.e.apps.data.enums.isUnFiltered import foundation.e.apps.data.fdroid.FdroidWebInterface +import foundation.e.apps.data.fused.FusedApi.Companion.APP_TYPE_ANY +import foundation.e.apps.data.fused.FusedApi.Companion.APP_TYPE_OPEN +import foundation.e.apps.data.fused.FusedApi.Companion.APP_TYPE_PWA import foundation.e.apps.data.fused.data.FusedApp import foundation.e.apps.data.fused.data.FusedCategory import foundation.e.apps.data.fused.data.FusedHome @@ -81,7 +84,7 @@ typealias GplaySearchResultFlow = Flow, Boolea typealias FusedHomeDeferred = Deferred>> @Singleton -class FusedAPIImpl @Inject constructor( +class FusedApiImpl @Inject constructor( private val pkgManagerModule: PkgManagerModule, private val pwaManagerModule: PWAManagerModule, private val preferenceManagerModule: PreferenceManagerModule, @@ -90,20 +93,10 @@ class FusedAPIImpl @Inject constructor( @Named("cleanApkAppsRepository") private val cleanApkAppsRepository: CleanApkRepository, @Named("cleanApkPWARepository") private val cleanApkPWARepository: CleanApkRepository, @ApplicationContext private val context: Context -) { +) : FusedApi { companion object { private const val CATEGORY_TITLE_REPLACEABLE_CONJUNCTION = "&" - - /* - * Removing "private" access specifier to allow access in - * MainActivityViewModel.timeoutAlertDialog - * - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 - */ - const val APP_TYPE_ANY = "any" - const val APP_TYPE_OPEN = "open" - const val APP_TYPE_PWA = "pwa" private const val CATEGORY_OPEN_GAMES_ID = "game_open_games" private const val CATEGORY_OPEN_GAMES_TITLE = "Open games" } @@ -113,14 +106,14 @@ class FusedAPIImpl @Inject constructor( * If any list is not empty, send false. * Else (if all lists are empty) send true. */ - fun isFusedHomesEmpty(fusedHomes: List): Boolean { + override fun isHomesEmpty(fusedHomes: List): Boolean { fusedHomes.forEach { if (it.list.isNotEmpty()) return false } return true } - fun getApplicationCategoryPreference(): List { + override fun getApplicationCategoryPreference(): List { val prefs = mutableListOf() if (preferenceManagerModule.isGplaySelected()) prefs.add(APP_TYPE_ANY) if (preferenceManagerModule.isOpenSourceSelected()) prefs.add(APP_TYPE_OPEN) @@ -128,7 +121,7 @@ class FusedAPIImpl @Inject constructor( return prefs } - suspend fun getHomeScreenData( + override suspend fun getHomeScreenData( authData: AuthData, ): LiveData>> { @@ -225,7 +218,7 @@ class FusedAPIImpl @Inject constructor( * * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 */ - suspend fun getCategoriesList( + override suspend fun getCategoriesList( type: CategoryType, ): Triple, String, ResultStatus> { val categoriesList = mutableListOf() @@ -251,7 +244,7 @@ class FusedAPIImpl @Inject constructor( * a Boolean signifying if more search results are being loaded. * Observe this livedata to display new apps as they are fetched from the network. */ - fun getSearchResults( + override fun getSearchResults( query: String, authData: AuthData ): LiveData, Boolean>>> { @@ -274,7 +267,7 @@ class FusedAPIImpl @Inject constructor( if (preferenceManagerModule.isOpenSourceSelected()) { fetchOpenSourceSearchResult( - this@FusedAPIImpl, + this@FusedApiImpl, cleanApkResults, query, searchResult, @@ -284,7 +277,7 @@ class FusedAPIImpl @Inject constructor( if (preferenceManagerModule.isPWASelected()) { fetchPWASearchResult( - this@FusedAPIImpl, + this@FusedApiImpl, query, searchResult, packageSpecificResults @@ -304,7 +297,7 @@ class FusedAPIImpl @Inject constructor( } private suspend fun fetchPWASearchResult( - fusedAPIImpl: FusedAPIImpl, + fusedAPIImpl: FusedApiImpl, query: String, searchResult: MutableList, packageSpecificResults: ArrayList @@ -358,7 +351,7 @@ class FusedAPIImpl @Inject constructor( } private suspend fun fetchOpenSourceSearchResult( - fusedAPIImpl: FusedAPIImpl, + fusedAPIImpl: FusedApiImpl, cleanApkResults: MutableList, query: String, searchResult: MutableList, @@ -490,11 +483,11 @@ class FusedAPIImpl @Inject constructor( return ResultSupreme.create(status, fusedApp) } - suspend fun getSearchSuggestions(query: String): List { + override suspend fun getSearchSuggestions(query: String): List { return gplayRepository.getSearchSuggestions(query) } - suspend fun getOnDemandModule( + override suspend fun getOnDemandModule( packageName: String, moduleName: String, versionCode: Int, @@ -514,7 +507,7 @@ class FusedAPIImpl @Inject constructor( return null } - suspend fun updateFusedDownloadWithDownloadingInfo( + override suspend fun updateFusedDownloadWithDownloadingInfo( origin: Origin, fusedDownload: FusedDownload ) { @@ -545,10 +538,10 @@ class FusedAPIImpl @Inject constructor( fusedDownload.downloadURLList = list } - suspend fun getOSSDownloadInfo(id: String, version: String?) = + override suspend fun getOSSDownloadInfo(id: String, version: String?) = (cleanApkAppsRepository as CleanApkDownloadInfoFetcher).getDownloadInfo(id, version) - suspend fun getPWAApps(category: String): ResultSupreme> { + override suspend fun getPWAApps(category: String): ResultSupreme> { val list = mutableListOf() val status = runCodeBlockWithTimeout({ val response = getPWAAppsResponse(category) @@ -562,7 +555,7 @@ class FusedAPIImpl @Inject constructor( return ResultSupreme.create(status, list) } - suspend fun getOpenSourceApps(category: String): ResultSupreme> { + override suspend fun getOpenSourceApps(category: String): ResultSupreme> { val list = mutableListOf() val status = runCodeBlockWithTimeout({ val response = getOpenSourceAppsResponse(category) @@ -576,7 +569,7 @@ class FusedAPIImpl @Inject constructor( return ResultSupreme.create(status, list) } - suspend fun getNextStreamBundle( + override suspend fun getNextStreamBundle( homeUrl: String, currentStreamBundle: StreamBundle, ): ResultSupreme { @@ -588,9 +581,9 @@ class FusedAPIImpl @Inject constructor( return ResultSupreme.create(status, streamBundle) } - suspend fun getAdjustedFirstCluster( + override suspend fun getAdjustedFirstCluster( streamBundle: StreamBundle, - pointer: Int = 0, + pointer: Int, ): ResultSupreme { var streamCluster = StreamCluster() val status = runCodeBlockWithTimeout({ @@ -600,7 +593,7 @@ class FusedAPIImpl @Inject constructor( return ResultSupreme.create(status, streamCluster) } - suspend fun getNextStreamCluster( + override suspend fun getNextStreamCluster( currentStreamCluster: StreamCluster, ): ResultSupreme { var streamCluster = StreamCluster() @@ -611,7 +604,7 @@ class FusedAPIImpl @Inject constructor( return ResultSupreme.create(status, streamCluster) } - suspend fun getPlayStoreApps( + override suspend fun getPlayStoreApps( browseUrl: String, ): ResultSupreme> { val list = mutableListOf() @@ -631,7 +624,7 @@ class FusedAPIImpl @Inject constructor( * * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5509 */ - suspend fun getCleanapkAppDetails(packageName: String): Pair { + override suspend fun getCleanapkAppDetails(packageName: String): Pair { var fusedApp = FusedApp() val status = runCodeBlockWithTimeout({ val result = cleanApkAppsRepository.getSearchResult( @@ -649,7 +642,7 @@ class FusedAPIImpl @Inject constructor( return Pair(fusedApp, status) } - suspend fun getApplicationDetails( + override suspend fun getApplicationDetails( packageNameList: List, authData: AuthData, origin: Origin @@ -762,7 +755,7 @@ class FusedAPIImpl @Inject constructor( * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5174 * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5131 [2] */ - suspend fun filterRestrictedGPlayApps( + override suspend fun filterRestrictedGPlayApps( authData: AuthData, appList: List, ): ResultSupreme> { @@ -787,7 +780,7 @@ class FusedAPIImpl @Inject constructor( * Get different filter levels. * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5720 */ - suspend fun getAppFilterLevel(fusedApp: FusedApp, authData: AuthData?): FilterLevel { + override suspend fun getAppFilterLevel(fusedApp: FusedApp, authData: AuthData?): FilterLevel { if (fusedApp.package_name.isBlank()) { return FilterLevel.UNKNOWN } @@ -839,7 +832,7 @@ class FusedAPIImpl @Inject constructor( /* * Similar to above method but uses Aurora OSS data class "App". */ - suspend fun getAppFilterLevel(app: App, authData: AuthData): FilterLevel { + override suspend fun getAppFilterLevel(app: App, authData: AuthData): FilterLevel { return getAppFilterLevel(app.transformToFusedApp(), authData) } @@ -850,7 +843,7 @@ class FusedAPIImpl @Inject constructor( this.filterLevel = getAppFilterLevel(this, authData) } - suspend fun getApplicationDetails( + override suspend fun getApplicationDetails( id: String, packageName: String, authData: AuthData, @@ -1345,7 +1338,7 @@ class FusedAPIImpl @Inject constructor( * * Recommended to use this instead of [PkgManagerModule.getPackageStatus]. */ - fun getFusedAppInstallationStatus(fusedApp: FusedApp): Status { + override fun getFusedAppInstallationStatus(fusedApp: FusedApp): Status { return if (fusedApp.is_pwa) { pwaManagerModule.getPwaStatus(fusedApp) } else { @@ -1382,7 +1375,7 @@ class FusedAPIImpl @Inject constructor( /** * @return true, if any change is found, otherwise false */ - fun isHomeDataUpdated( + override fun isHomeDataUpdated( newHomeData: List, oldHomeData: List ): Boolean { @@ -1421,7 +1414,7 @@ class FusedAPIImpl @Inject constructor( /** * @return returns true if there is changes in data, otherwise false */ - fun isAnyFusedAppUpdated( + override fun isAnyFusedAppUpdated( newFusedApps: List, oldFusedApps: List ): Boolean { @@ -1439,7 +1432,7 @@ class FusedAPIImpl @Inject constructor( return false } - fun isAnyAppInstallStatusChanged(currentList: List): Boolean { + override fun isAnyAppInstallStatusChanged(currentList: List): Boolean { currentList.forEach { if (it.status == Status.INSTALLATION_ISSUE) { return@forEach @@ -1453,5 +1446,5 @@ class FusedAPIImpl @Inject constructor( return false } - fun isOpenSourceSelected() = preferenceManagerModule.isOpenSourceSelected() + override fun isOpenSourceSelected() = preferenceManagerModule.isOpenSourceSelected() } diff --git a/app/src/main/java/foundation/e/apps/data/fusedDownload/FusedDownloadDAO.kt b/app/src/main/java/foundation/e/apps/data/fusedDownload/FusedDownloadDAO.kt index 02b3fbfeba8d81020a9ac7d65658ed89da334a0a..9b3b096efcbaaf421a827c8c0e89e86b68e61de4 100644 --- a/app/src/main/java/foundation/e/apps/data/fusedDownload/FusedDownloadDAO.kt +++ b/app/src/main/java/foundation/e/apps/data/fusedDownload/FusedDownloadDAO.kt @@ -25,7 +25,7 @@ interface FusedDownloadDAO { suspend fun getDownloadById(id: String): FusedDownload? @Query("SELECT * FROM fuseddownload where id = :id") - fun getDownloadFlowById(id: String): LiveData + fun getDownloadFlowById(id: String): LiveData @Update suspend fun updateDownload(fusedDownload: FusedDownload) diff --git a/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerImpl.kt b/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerImpl.kt index d537ade94c75b36a0005b008435722aeccb75dfd..44f569778175ecd8b8250c284119cadfb2fa5112 100644 --- a/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerImpl.kt @@ -29,8 +29,8 @@ import foundation.e.apps.data.enums.Status import foundation.e.apps.data.enums.isUnFiltered import foundation.e.apps.data.faultyApps.FaultyAppRepository import foundation.e.apps.data.fdroid.FdroidRepository -import foundation.e.apps.data.fused.FusedAPIImpl.Companion.APP_TYPE_ANY import foundation.e.apps.data.fused.FusedAPIRepository +import foundation.e.apps.data.fused.FusedApi.Companion.APP_TYPE_ANY import foundation.e.apps.data.fused.data.FusedApp import foundation.e.apps.data.preference.PreferenceManagerModule import foundation.e.apps.install.pkg.PkgManagerModule diff --git a/app/src/main/java/foundation/e/apps/di/RepositoryModule.kt b/app/src/main/java/foundation/e/apps/di/RepositoryModule.kt index ddaff28495b7fd28204e5c2b5e8f085207506da4..818511a208bb4f2f22422e59c44659bc19993eef 100644 --- a/app/src/main/java/foundation/e/apps/di/RepositoryModule.kt +++ b/app/src/main/java/foundation/e/apps/di/RepositoryModule.kt @@ -8,6 +8,8 @@ import foundation.e.apps.data.exodus.repositories.AppPrivacyInfoRepositoryImpl import foundation.e.apps.data.exodus.repositories.IAppPrivacyInfoRepository import foundation.e.apps.data.fdroid.FdroidRepository import foundation.e.apps.data.fdroid.IFdroidRepository +import foundation.e.apps.data.fused.FusedApi +import foundation.e.apps.data.fused.FusedApiImpl import foundation.e.apps.data.fusedDownload.FusedManagerImpl import foundation.e.apps.data.fusedDownload.IFusedManager import javax.inject.Singleton @@ -26,4 +28,8 @@ interface RepositoryModule { @Singleton @Binds fun getFdroidRepository(fusedManagerImpl: FdroidRepository): IFdroidRepository + + @Singleton + @Binds + fun getFusedApi(fusedApiImpl: FusedApiImpl): FusedApi } diff --git a/app/src/main/java/foundation/e/apps/install/pkg/PkgManagerModule.kt b/app/src/main/java/foundation/e/apps/install/pkg/PkgManagerModule.kt index 076c05a8fab8a902c7a7a7ffe884360a4e443c0c..bbb7db23224b4c8bbc36366cdc668a09fd6f76fe 100644 --- a/app/src/main/java/foundation/e/apps/install/pkg/PkgManagerModule.kt +++ b/app/src/main/java/foundation/e/apps/install/pkg/PkgManagerModule.kt @@ -34,7 +34,7 @@ import foundation.e.apps.OpenForTesting import foundation.e.apps.data.enums.Origin import foundation.e.apps.data.enums.Status import foundation.e.apps.data.enums.Type -import foundation.e.apps.data.fused.FusedAPIImpl +import foundation.e.apps.data.fused.FusedApi import foundation.e.apps.data.fusedDownload.models.FusedDownload import kotlinx.coroutines.DelicateCoroutinesApi import timber.log.Timber @@ -88,7 +88,7 @@ class PkgManagerModule @Inject constructor( * This method should be only used for native apps! * If you are using for any FusedApp, please consider that it can be a PWA! * - * Recommended to use: [FusedAPIImpl.getFusedAppInstallationStatus]. + * Recommended to use: [FusedApi.getFusedAppInstallationStatus]. */ fun getPackageStatus(packageName: String, versionCode: Int): Status { return if (isInstalled(packageName)) { diff --git a/app/src/main/java/foundation/e/apps/ui/home/HomeViewModel.kt b/app/src/main/java/foundation/e/apps/ui/home/HomeViewModel.kt index 2fbea50a82b3f3f88b6bd4f261b6d6b9cb5f385a..981761b85581a4919aa304a38f98e0bb909b9de8 100644 --- a/app/src/main/java/foundation/e/apps/ui/home/HomeViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/home/HomeViewModel.kt @@ -97,9 +97,9 @@ class HomeViewModel @Inject constructor( return fusedAPIRepository.getApplicationCategoryPreference() } - fun isFusedHomesEmpty(): Boolean { + fun isHomesEmpty(): Boolean { return homeScreenData.value?.data?.let { - fusedAPIRepository.isFusedHomesEmpty(it) + fusedAPIRepository.isHomesEmpty(it) } ?: true } diff --git a/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt b/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt index 69404eac1f371a5f8446744c8de730c1d1721946..13ec4be744df6f8b7da13f02030c53f9c9253242 100644 --- a/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt +++ b/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt @@ -20,31 +20,32 @@ package foundation.e.apps import android.content.Context import android.text.format.Formatter import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import com.aurora.gplayapi.Constants import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category -import foundation.e.apps.data.cleanapk.CleanApkRetrofit import foundation.e.apps.data.cleanapk.data.categories.Categories import foundation.e.apps.data.cleanapk.data.search.Search -import foundation.e.apps.data.cleanapk.repositories.CleanAPKRepository +import foundation.e.apps.data.cleanapk.repositories.CleanApkRepository import foundation.e.apps.data.enums.FilterLevel import foundation.e.apps.data.enums.Origin import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.enums.Status import foundation.e.apps.data.fdroid.FdroidWebInterface -import foundation.e.apps.data.fused.FusedAPIImpl +import foundation.e.apps.data.fused.FusedApiImpl import foundation.e.apps.data.fused.data.FusedApp import foundation.e.apps.data.fused.data.FusedHome -import foundation.e.apps.data.gplay.GPlayAPIRepository +import foundation.e.apps.data.fused.utils.CategoryType +import foundation.e.apps.data.gplay.GplayStoreRepository import foundation.e.apps.install.pkg.PWAManagerModule import foundation.e.apps.install.pkg.PkgManagerModule import foundation.e.apps.util.MainCoroutineRule import foundation.e.apps.util.getOrAwaitValue import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -73,7 +74,7 @@ class FusedApiImplTest { @get:Rule var mainCoroutineRule = MainCoroutineRule() - private lateinit var fusedAPIImpl: FusedAPIImpl + private lateinit var fusedAPIImpl: FusedApiImpl @Mock private lateinit var pwaManagerModule: PWAManagerModule @@ -85,10 +86,13 @@ class FusedApiImplTest { private lateinit var context: Context @Mock - private lateinit var cleanApkRepository: CleanAPKRepository + private lateinit var cleanApkAppsRepository: CleanApkRepository @Mock - private lateinit var gPlayAPIRepository: GPlayAPIRepository + private lateinit var cleanApkPWARepository: CleanApkRepository + + @Mock + private lateinit var gPlayAPIRepository: GplayStoreRepository @Mock private lateinit var fdroidWebInterface: FdroidWebInterface @@ -106,13 +110,14 @@ class FusedApiImplTest { MockitoAnnotations.openMocks(this) formatterMocked = Mockito.mockStatic(Formatter::class.java) preferenceManagerModule = FakePreferenceModule(context) - fusedAPIImpl = FusedAPIImpl( - cleanApkRepository, - gPlayAPIRepository, + fusedAPIImpl = FusedApiImpl( pkgManagerModule, pwaManagerModule, preferenceManagerModule, fdroidWebInterface, + gPlayAPIRepository, + cleanApkAppsRepository, + cleanApkPWARepository, context ) } @@ -543,7 +548,7 @@ class FusedApiImplTest { price = "" ) - Mockito.`when`(gPlayAPIRepository.getAppDetails(fusedApp.package_name, AUTH_DATA)) + Mockito.`when`(gPlayAPIRepository.getAppDetails(fusedApp.package_name)) .thenReturn(App(fusedApp.package_name)) Mockito.`when`( @@ -551,7 +556,6 @@ class FusedApiImplTest { fusedApp.package_name, fusedApp.latest_version_code, fusedApp.offer_type, - AUTH_DATA ) ).thenReturn(listOf()) @@ -572,12 +576,12 @@ class FusedApiImplTest { price = "" ) - Mockito.`when`(gPlayAPIRepository.getAppDetails(fusedApp.package_name, AUTH_DATA)) + Mockito.`when`(gPlayAPIRepository.getAppDetails(fusedApp.package_name)) .thenThrow(RuntimeException()) Mockito.`when`( gPlayAPIRepository.getDownloadInfo( - fusedApp.package_name, fusedApp.latest_version_code, fusedApp.offer_type, AUTH_DATA + fusedApp.package_name, fusedApp.latest_version_code, fusedApp.offer_type ) ).thenReturn(listOf()) @@ -598,12 +602,12 @@ class FusedApiImplTest { price = "" ) - Mockito.`when`(gPlayAPIRepository.getAppDetails(fusedApp.package_name, AUTH_DATA)) + Mockito.`when`(gPlayAPIRepository.getAppDetails(fusedApp.package_name)) .thenReturn(App(fusedApp.package_name)) Mockito.`when`( gPlayAPIRepository.getDownloadInfo( - fusedApp.package_name, fusedApp.latest_version_code, fusedApp.offer_type, AUTH_DATA + fusedApp.package_name, fusedApp.latest_version_code, fusedApp.offer_type ) ).thenThrow(RuntimeException()) @@ -621,15 +625,13 @@ class FusedApiImplTest { preferenceManagerModule.isGplaySelectedFake = false Mockito.`when`( - cleanApkRepository.getCategoriesList( - eq(CleanApkRetrofit.APP_TYPE_PWA), eq(CleanApkRetrofit.APP_SOURCE_ANY) - ) + cleanApkPWARepository.getCategories() ).thenReturn(response) Mockito.`when`(context.getString(eq(R.string.pwa))).thenReturn("PWA") val categoryListResponse = - fusedAPIImpl.getCategoriesList(Category.Type.APPLICATION, AUTH_DATA) + fusedAPIImpl.getCategoriesList(CategoryType.APPLICATION) assertEquals("getCategory", 3, categoryListResponse.first.size) } @@ -645,14 +647,12 @@ class FusedApiImplTest { preferenceManagerModule.isGplaySelectedFake = false Mockito.`when`( - cleanApkRepository.getCategoriesList( - eq(CleanApkRetrofit.APP_TYPE_ANY), eq(CleanApkRetrofit.APP_SOURCE_FOSS) - ) + cleanApkAppsRepository.getCategories() ).thenReturn(response) Mockito.`when`(context.getString(eq(R.string.open_source))).thenReturn("Open source") val categoryListResponse = - fusedAPIImpl.getCategoriesList(Category.Type.APPLICATION, AUTH_DATA) + fusedAPIImpl.getCategoriesList(CategoryType.APPLICATION) assertEquals("getCategory", 3, categoryListResponse.first.size) } @@ -666,11 +666,11 @@ class FusedApiImplTest { preferenceManagerModule.isGplaySelectedFake = true Mockito.`when`( - gPlayAPIRepository.getCategoriesList(Category.Type.APPLICATION, AUTH_DATA) + gPlayAPIRepository.getCategories(CategoryType.APPLICATION) ).thenReturn(categories) val categoryListResponse = - fusedAPIImpl.getCategoriesList(Category.Type.APPLICATION, AUTH_DATA) + fusedAPIImpl.getCategoriesList(CategoryType.APPLICATION) assertEquals("getCategory", 4, categoryListResponse.first.size) } @@ -682,11 +682,11 @@ class FusedApiImplTest { preferenceManagerModule.isGplaySelectedFake = true Mockito.`when`( - gPlayAPIRepository.getCategoriesList(Category.Type.APPLICATION, AUTH_DATA) + gPlayAPIRepository.getCategories(CategoryType.APPLICATION) ).thenThrow(RuntimeException()) val categoryListResponse = - fusedAPIImpl.getCategoriesList(Category.Type.APPLICATION, AUTH_DATA) + fusedAPIImpl.getCategoriesList(CategoryType.APPLICATION) assertEquals("getCategory", 0, categoryListResponse.first.size) assertEquals("getCategory", ResultStatus.UNKNOWN, categoryListResponse.third) @@ -704,19 +704,15 @@ class FusedApiImplTest { val pwaResponse = Response.success(pwaCategories) Mockito.`when`( - cleanApkRepository.getCategoriesList( - eq(CleanApkRetrofit.APP_TYPE_ANY), eq(CleanApkRetrofit.APP_SOURCE_FOSS) - ) + cleanApkAppsRepository.getCategories() ).thenReturn(openSourceResponse) Mockito.`when`( - cleanApkRepository.getCategoriesList( - eq(CleanApkRetrofit.APP_TYPE_PWA), eq(CleanApkRetrofit.APP_SOURCE_ANY) - ) + cleanApkPWARepository.getCategories() ).thenReturn(pwaResponse) Mockito.`when`( - gPlayAPIRepository.getCategoriesList(Category.Type.APPLICATION, AUTH_DATA) + gPlayAPIRepository.getCategories(CategoryType.APPLICATION) ).thenReturn(gplayCategories) Mockito.`when`(context.getString(eq(R.string.open_source))).thenReturn("Open source") @@ -727,7 +723,7 @@ class FusedApiImplTest { preferenceManagerModule.isGplaySelectedFake = true val categoryListResponse = - fusedAPIImpl.getCategoriesList(Category.Type.APPLICATION, AUTH_DATA) + fusedAPIImpl.getCategoriesList(CategoryType.APPLICATION) assertEquals("getCategory", 11, categoryListResponse.first.size) } @@ -758,21 +754,21 @@ class FusedApiImplTest { ) ) - val searchResult = Search(apps = appList, numberOfResults = 1, success = true) + val searchResult = Search(apps = appList, numberOfResults = 3, success = true) val packageNameSearchResponse = Response.success(searchResult) val gplayPackageResult = App("com.search.package") preferenceManagerModule.isPWASelectedFake = true preferenceManagerModule.isOpenSourceelectedFake = true preferenceManagerModule.isGplaySelectedFake = true - val gplayLivedata: LiveData, Boolean>> = MutableLiveData( + val gplayFlow: Flow, Boolean>> = flowOf( Pair( - listOf(FusedApp("a.b.c"), FusedApp("c.d.e"), FusedApp("d.e.f"), FusedApp("d.e.g")), false + listOf(App("a.b.c"), App("c.d.e"), App("d.e.f"), App("d.e.g")), false ) ) setupMockingSearchApp( - packageNameSearchResponse, AUTH_DATA, gplayPackageResult, gplayLivedata + packageNameSearchResponse, gplayPackageResult, gplayFlow ) val searchResultLiveData = @@ -784,52 +780,43 @@ class FusedApiImplTest { private suspend fun setupMockingSearchApp( packageNameSearchResponse: Response?, - authData: AuthData, gplayPackageResult: App, - gplayLivedata: LiveData, Boolean>>?, + gplayLivedata: Flow, Boolean>>, willThrowException: Boolean = false ) { Mockito.`when`(pwaManagerModule.getPwaStatus(any())).thenReturn(Status.UNAVAILABLE) Mockito.`when`(pkgManagerModule.getPackageStatus(any(), any())) .thenReturn(Status.UNAVAILABLE) Mockito.`when`( - cleanApkRepository.searchApps( - keyword = "com.search.package", by = "package_name" + cleanApkAppsRepository.getSearchResult( + query = "com.search.package", searchBy = "package_name" ) ).thenReturn(packageNameSearchResponse) formatterMocked.`when` { Formatter.formatFileSize(any(), any()) }.thenReturn("15MB") if (willThrowException) { - Mockito.`when`(gPlayAPIRepository.getAppDetails("com.search.package", authData)) + Mockito.`when`(gPlayAPIRepository.getAppDetails("com.search.package")) .thenThrow(RuntimeException()) } else { - Mockito.`when`(gPlayAPIRepository.getAppDetails(eq("com.search.package"), eq(authData))) + Mockito.`when`(gPlayAPIRepository.getAppDetails(eq("com.search.package"))) .thenReturn(gplayPackageResult) } - Mockito.`when`(cleanApkRepository.searchApps(keyword = "com.search.package")) + Mockito.`when`(cleanApkAppsRepository.getSearchResult(query = "com.search.package")) + .thenReturn(packageNameSearchResponse) + + Mockito.`when`(cleanApkPWARepository.getSearchResult(query = "com.search.package")) .thenReturn(packageNameSearchResponse) Mockito.`when`( - cleanApkRepository.searchApps( - keyword = "com.search.package", - type = CleanApkRetrofit.APP_TYPE_PWA, - source = CleanApkRetrofit.APP_SOURCE_ANY + cleanApkAppsRepository.getSearchResult( + query = "com.search.package" ) ).thenReturn(packageNameSearchResponse) - suspend fun replaceWithFDroid(gPlayApp: App): FusedApp { - return FusedApp(gPlayApp.id.toString(), gPlayApp.packageName) - } + Mockito.`when`(fdroidWebInterface.getFdroidApp(any())).thenReturn(Response.error(404, "".toResponseBody(null))) - Mockito.`when`( - gPlayAPIRepository.getSearchResults( - eq("com.search.package"), - eq(authData), - eq(::replaceWithFDroid) - ) - ) - .thenReturn(gplayLivedata) + Mockito.`when`(gPlayAPIRepository.getSearchResult(eq("com.search.package"),)).thenReturn(gplayLivedata) } @Test @@ -862,11 +849,14 @@ class FusedApiImplTest { val packageNameSearchResponse = Response.success(searchResult) val gplayPackageResult = App("com.search.package") - val gplayLivedata = - MutableLiveData(Pair(listOf(FusedApp("a.b.c"), FusedApp("c.d.e"), FusedApp("d.e.f")), false)) + val gplayFlow: Flow, Boolean>> = flowOf( + Pair( + listOf(App("a.b.c"), App("c.d.e"), App("d.e.f"), App("d.e.g")), false + ) + ) setupMockingSearchApp( - packageNameSearchResponse, AUTH_DATA, gplayPackageResult, gplayLivedata, true + packageNameSearchResponse, gplayPackageResult, gplayFlow, true ) preferenceManagerModule.isPWASelectedFake = false @@ -877,6 +867,6 @@ class FusedApiImplTest { fusedAPIImpl.getSearchResults("com.search.package", AUTH_DATA).getOrAwaitValue() val size = searchResultLiveData.data?.first?.size ?: -2 - assertEquals("getSearchResult", 3, size) + assertEquals("getSearchResult", 4, size) } } diff --git a/app/src/test/java/foundation/e/apps/FusedApiRepositoryTest.kt b/app/src/test/java/foundation/e/apps/FusedApiRepositoryTest.kt index 8fc89ed419423c4f65733057b8479eb4e51c0860..8e04ce98e89935f2a9afa77e22afd23f0523aab3 100644 --- a/app/src/test/java/foundation/e/apps/FusedApiRepositoryTest.kt +++ b/app/src/test/java/foundation/e/apps/FusedApiRepositoryTest.kt @@ -17,8 +17,8 @@ package foundation.e.apps -import foundation.e.apps.data.fused.FusedAPIImpl import foundation.e.apps.data.fused.FusedAPIRepository +import foundation.e.apps.data.fused.FusedApiImpl import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -30,7 +30,7 @@ import org.mockito.kotlin.any class FusedApiRepositoryTest { private lateinit var fusedApiRepository: FusedAPIRepository @Mock - private lateinit var fusedAPIImpl: FusedAPIImpl + private lateinit var fusedAPIImpl: FusedApiImpl @Before fun setup() { diff --git a/app/src/test/java/foundation/e/apps/UpdateManagerImptTest.kt b/app/src/test/java/foundation/e/apps/UpdateManagerImptTest.kt index 44ceaa8cc9c088d18afaecfd382e92a4567ee045..fb78eeedac10288d78017add65b017e0fc72ddea 100644 --- a/app/src/test/java/foundation/e/apps/UpdateManagerImptTest.kt +++ b/app/src/test/java/foundation/e/apps/UpdateManagerImptTest.kt @@ -17,6 +17,7 @@ package foundation.e.apps +import android.content.Context import android.content.pm.ApplicationInfo import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.aurora.gplayapi.data.models.AuthData @@ -25,8 +26,9 @@ import foundation.e.apps.data.enums.Origin import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.enums.Status import foundation.e.apps.data.faultyApps.FaultyAppRepository -import foundation.e.apps.data.fused.FusedAPIImpl +import foundation.e.apps.data.fdroid.FdroidRepository import foundation.e.apps.data.fused.FusedAPIRepository +import foundation.e.apps.data.fused.FusedApi import foundation.e.apps.data.fused.data.FusedApp import foundation.e.apps.data.updates.UpdatesManagerImpl import foundation.e.apps.install.pkg.PkgManagerModule @@ -59,14 +61,22 @@ class UpdateManagerImptTest { private lateinit var updatesManagerImpl: UpdatesManagerImpl + @Mock + private lateinit var context: Context + @Mock private lateinit var pkgManagerModule: PkgManagerModule @Mock private lateinit var fusedAPIRepository: FusedAPIRepository + private lateinit var preferenceModule: FakePreferenceModule + private lateinit var faultyAppRepository: FaultyAppRepository + @Mock + private lateinit var fdroidRepository: FdroidRepository + val authData = AuthData("e@e.email", "AtadyMsIAtadyM") val applicationInfo = mutableListOf( @@ -79,8 +89,9 @@ class UpdateManagerImptTest { fun setup() { MockitoAnnotations.openMocks(this) faultyAppRepository = FaultyAppRepository(FakeFaultyAppDao()) + preferenceModule = FakePreferenceModule(context) updatesManagerImpl = - UpdatesManagerImpl(pkgManagerModule, fusedAPIRepository, faultyAppRepository) + UpdatesManagerImpl(context, pkgManagerModule, fusedAPIRepository, faultyAppRepository, preferenceModule, fdroidRepository) } @Test @@ -296,9 +307,9 @@ class UpdateManagerImptTest { openSourceUpdates: Pair, ResultStatus>, gplayUpdates: Pair, ResultStatus>, selectedApplicationSources: List = mutableListOf( - FusedAPIImpl.APP_TYPE_ANY, - FusedAPIImpl.APP_TYPE_OPEN, - FusedAPIImpl.APP_TYPE_PWA + FusedApi.APP_TYPE_ANY, + FusedApi.APP_TYPE_OPEN, + FusedApi.APP_TYPE_PWA ) ) { Mockito.`when`(pkgManagerModule.getAllUserApps()).thenReturn(applicationInfo) diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt index 546b758694fbef3c02eea498d8394a6799f2cfac..66ad4230510b3595eadec569f87153eedd43a69f 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -152,7 +152,7 @@ class AppInstallProcessorTest { fakeFusedManagerRepository.forceCrash = true val finalFusedDownload = runProcessInstall(fusedDownload) - assertEquals("processInstall", Status.INSTALLATION_ISSUE, finalFusedDownload?.status) + assertTrue("processInstall", finalFusedDownload == null || fusedDownload.status == Status.INSTALLATION_ISSUE) } @Test diff --git a/app/src/test/java/foundation/e/apps/installProcessor/FakeFusedDownloadDAO.kt b/app/src/test/java/foundation/e/apps/installProcessor/FakeFusedDownloadDAO.kt index f47693a5007d3f99ef880bcbc91e9e3b69d0c637..dbec4d02ef7c8beb4008961ad8f804fa43e43240 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/FakeFusedDownloadDAO.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/FakeFusedDownloadDAO.kt @@ -19,8 +19,11 @@ package foundation.e.apps.installProcessor import androidx.lifecycle.LiveData +import androidx.lifecycle.asLiveData +import foundation.e.apps.data.enums.Status import foundation.e.apps.data.fusedDownload.FusedDownloadDAO import foundation.e.apps.data.fusedDownload.models.FusedDownload +import kotlinx.coroutines.flow.flow class FakeFusedDownloadDAO : FusedDownloadDAO { val fusedDownloadList = mutableListOf() @@ -41,8 +44,16 @@ class FakeFusedDownloadDAO : FusedDownloadDAO { return fusedDownloadList.find { it.id == id } } - override fun getDownloadFlowById(id: String): LiveData { - TODO("Not yet implemented") + override fun getDownloadFlowById(id: String): LiveData { + return flow { + while (true) { + val fusedDownload = fusedDownloadList.find { it.id == id } + emit(fusedDownload) + if (fusedDownload == null || fusedDownload.status == Status.INSTALLATION_ISSUE) { + break + } + } + }.asLiveData() } override suspend fun updateDownload(fusedDownload: FusedDownload) { diff --git a/app/src/test/java/foundation/e/apps/installProcessor/FakeFusedManagerRepository.kt b/app/src/test/java/foundation/e/apps/installProcessor/FakeFusedManagerRepository.kt index 179b4837a6977f73f14618cbf2928118aae619cf..9b39077a2319b34ff5f9c7a686b54cec078aa5ba 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/FakeFusedManagerRepository.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/FakeFusedManagerRepository.kt @@ -37,6 +37,11 @@ class FakeFusedManagerRepository( var forceCrash = false override suspend fun downloadApp(fusedDownload: FusedDownload) { + if (forceCrash) { + System.out.println("Throwing test exception") + throw Exception("test exception!") + } + fusedDownload.status = Status.DOWNLOADING fusedDownload.downloadIdMap = mutableMapOf(Pair(341, false), Pair(342, false)) fusedDownloadDAO.updateDownload(fusedDownload) @@ -53,6 +58,7 @@ class FakeFusedManagerRepository( fusedDownload.downloadIdMap.replaceAll { _, _ -> true } fusedDownload.status = Status.DOWNLOADED fusedDownloadDAO.updateDownload(fusedDownload) + updateDownloadStatus(fusedDownload, Status.INSTALLING) } override suspend fun updateDownloadStatus(fusedDownload: FusedDownload, status: Status) { @@ -61,10 +67,6 @@ class FakeFusedManagerRepository( handleStatusInstalling(fusedDownload) } Status.INSTALLED -> { - if (forceCrash) { - throw RuntimeException() - } - fusedDownloadDAO.deleteDownload(fusedDownload) } else -> { @@ -100,4 +102,8 @@ class FakeFusedManagerRepository( override fun getFusedDownloadPackageStatus(fusedDownload: FusedDownload): Status { return installationStatus } + + override suspend fun cancelDownload(fusedDownload: FusedDownload) { + fusedDownloadDAO.deleteDownload(fusedDownload) + } } diff --git a/app/src/test/java/foundation/e/apps/util/LiveDataTestUtil.kt b/app/src/test/java/foundation/e/apps/util/LiveDataTestUtil.kt index 58e478c3403c125ee73da31fa21ff648d4d1fcc1..978c43b118fbd07b5a5fb63bd7c6567e018a4f2a 100644 --- a/app/src/test/java/foundation/e/apps/util/LiveDataTestUtil.kt +++ b/app/src/test/java/foundation/e/apps/util/LiveDataTestUtil.kt @@ -43,16 +43,3 @@ suspend fun LiveData.getOrAwaitValue( @Suppress("UNCHECKED_CAST") return data as T } - -/** - * Observes a [LiveData] until the `block` is done executing. - */ -suspend fun LiveData.observeForTesting(block: suspend () -> Unit) { - val observer = Observer { } - try { - observeForever(observer) - block() - } finally { - removeObserver(observer) - } -}