Repository: jonathanklee/Sapio
Branch: main
Commit: fde6ba30e24f
Files: 202
Total size: 463.2 KB
Directory structure:
gitextract_rftqr9mc/
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── android.yml
│ └── jekyll-gh-pages.yml
├── .gitignore
├── LICENSE
├── README.md
├── app/
│ ├── .gitignore
│ ├── build.gradle
│ ├── detekt-baseline.xml
│ ├── proguard-rules.pro
│ └── src/
│ ├── androidTest/
│ │ └── java/
│ │ └── com/
│ │ └── klee/
│ │ └── sapio/
│ │ └── ExampleInstrumentedTest.kt
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── klee/
│ │ │ └── sapio/
│ │ │ ├── SapioApplication.kt
│ │ │ ├── ui/
│ │ │ │ ├── model/
│ │ │ │ │ ├── InstalledAppWithRating.kt
│ │ │ │ │ ├── Label.kt
│ │ │ │ │ ├── Rating.kt
│ │ │ │ │ └── SharedEvaluation.kt
│ │ │ │ ├── state/
│ │ │ │ │ ├── AppEvaluationsUiState.kt
│ │ │ │ │ ├── ChooseAppUiState.kt
│ │ │ │ │ ├── EvaluateUiState.kt
│ │ │ │ │ ├── FeedUiState.kt
│ │ │ │ │ ├── MyAppsUiState.kt
│ │ │ │ │ └── SearchUiState.kt
│ │ │ │ ├── view/
│ │ │ │ │ ├── AboutFragment.kt
│ │ │ │ │ ├── ChooseAppAdapter.kt
│ │ │ │ │ ├── ChooseAppDialog.kt
│ │ │ │ │ ├── ChooseAppFragment.kt
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── ContributeFragment.kt
│ │ │ │ │ ├── EvaluateFragment.kt
│ │ │ │ │ ├── EvaluationsFragment.kt
│ │ │ │ │ ├── FeedAppAdapter.kt
│ │ │ │ │ ├── FeedFragment.kt
│ │ │ │ │ ├── FragmentAdapter.kt
│ │ │ │ │ ├── LoadingFragment.kt
│ │ │ │ │ ├── MainActivity.kt
│ │ │ │ │ ├── MyAppsAdapter.kt
│ │ │ │ │ ├── MyAppsFragment.kt
│ │ │ │ │ ├── PreferencesFragment.kt
│ │ │ │ │ ├── SearchAppAdapter.kt
│ │ │ │ │ ├── SearchFragment.kt
│ │ │ │ │ ├── ShareComposable.kt
│ │ │ │ │ ├── SplashActivity.kt
│ │ │ │ │ ├── SuccessFragment.kt
│ │ │ │ │ ├── ToastMessage.kt
│ │ │ │ │ └── WarningFragment.kt
│ │ │ │ └── viewmodel/
│ │ │ │ ├── AppEvaluationsViewModel.kt
│ │ │ │ ├── ChooseAppViewModel.kt
│ │ │ │ ├── EvaluateViewModel.kt
│ │ │ │ ├── FeedViewModel.kt
│ │ │ │ ├── LoadingViewModel.kt
│ │ │ │ ├── MyAppsViewModel.kt
│ │ │ │ └── SearchViewModel.kt
│ │ │ └── work/
│ │ │ ├── CompatibilityCheckScheduler.kt
│ │ │ ├── CompatibilityCheckWorker.kt
│ │ │ └── CompatibilityNotificationManager.kt
│ │ └── res/
│ │ ├── drawable/
│ │ │ ├── bg_label_rounded.xml
│ │ │ ├── ic_close.xml
│ │ │ ├── ic_info_background.xml
│ │ │ ├── ic_launcher_foreground.xml
│ │ │ ├── ic_notification_info.xml
│ │ │ ├── ic_phone.xml
│ │ │ ├── ic_settings.xml
│ │ │ ├── ic_status_green.xml
│ │ │ ├── ic_status_red.xml
│ │ │ └── ic_status_yellow.xml
│ │ ├── drawable-anydpi/
│ │ │ ├── ic_add.xml
│ │ │ ├── ic_search.xml
│ │ │ └── ic_settings.xml
│ │ ├── drawable-v33/
│ │ │ └── ic_launcher_monochrome.xml
│ │ ├── layout/
│ │ │ ├── activity_main.xml
│ │ │ ├── activity_splash.xml
│ │ │ ├── choose_app_card.xml
│ │ │ ├── dialog_choose_app.xml
│ │ │ ├── feed_app_card.xml
│ │ │ ├── fragment_about.xml
│ │ │ ├── fragment_choose_app.xml
│ │ │ ├── fragment_contribute.xml
│ │ │ ├── fragment_evaluate.xml
│ │ │ ├── fragment_evaluations.xml
│ │ │ ├── fragment_loading.xml
│ │ │ ├── fragment_main.xml
│ │ │ ├── fragment_my_apps.xml
│ │ │ ├── fragment_search.xml
│ │ │ ├── fragment_success.xml
│ │ │ ├── fragment_warning.xml
│ │ │ ├── my_app_card.xml
│ │ │ └── search_app_card.xml
│ │ ├── menu/
│ │ │ ├── bottom_menu.xml
│ │ │ └── menu.xml
│ │ ├── mipmap-anydpi-v26/
│ │ │ ├── ic_info.xml
│ │ │ ├── ic_info_round.xml
│ │ │ ├── ic_launcher.xml
│ │ │ ├── ic_launcher_round.xml
│ │ │ ├── search_icon.xml
│ │ │ └── search_icon_round.xml
│ │ ├── navigation/
│ │ │ └── nav_graph.xml
│ │ ├── raw/
│ │ │ └── loading.json
│ │ ├── values/
│ │ │ ├── colors.xml
│ │ │ ├── dimens.xml
│ │ │ ├── strings.xml
│ │ │ └── themes.xml
│ │ ├── values-de/
│ │ │ └── strings.xml
│ │ ├── values-es/
│ │ │ └── strings.xml
│ │ ├── values-fr/
│ │ │ └── strings.xml
│ │ ├── values-it/
│ │ │ └── strings.xml
│ │ ├── values-land/
│ │ │ └── dimens.xml
│ │ ├── values-night/
│ │ │ └── colors.xml
│ │ ├── values-night-v31/
│ │ │ └── themes.xml
│ │ ├── values-v31/
│ │ │ └── themes.xml
│ │ ├── values-w1240dp/
│ │ │ └── dimens.xml
│ │ ├── values-w600dp/
│ │ │ └── dimens.xml
│ │ └── xml/
│ │ └── preferences.xml
│ └── test/
│ └── java/
│ └── com/
│ └── klee/
│ └── sapio/
│ ├── AppEvaluationsViewModelTest.kt
│ ├── DeviceConfigurationTest.kt
│ ├── DomainUseCasesTest.kt
│ ├── EvaluateAppUseCaseBehaviourTest.kt
│ ├── EvaluateAppUseCaseTest.kt
│ ├── EvaluateViewModelTest.kt
│ ├── EvaluationServiceTest.kt
│ ├── FeedViewModelTest.kt
│ ├── InstalledApplicationsRepositoryTest.kt
│ ├── LoadingViewModelTest.kt
│ ├── RatingTest.kt
│ ├── SapioApplicationTest.kt
│ ├── SearchViewModelTest.kt
│ ├── SettingsTest.kt
│ ├── SystemPropertyReaderTest.kt
│ └── data/
│ ├── local/
│ │ └── EvaluationDaoTest.kt
│ └── repository/
│ └── EvaluationRepositoryImplTest.kt
├── build.gradle
├── data/
│ ├── build.gradle
│ └── src/
│ ├── main/
│ │ └── java/
│ │ └── com/
│ │ └── klee/
│ │ └── sapio/
│ │ └── data/
│ │ ├── api/
│ │ │ └── RetrofitClient.kt
│ │ ├── di/
│ │ │ └── DataModule.kt
│ │ ├── dto/
│ │ │ ├── Evaluation.kt
│ │ │ ├── IconDtos.kt
│ │ │ ├── StrapiDtos.kt
│ │ │ └── UploadDtos.kt
│ │ ├── fdroid/
│ │ │ ├── CachedFdroidAvailabilityChecker.kt
│ │ │ └── OkHttpFdroidAvailabilityChecker.kt
│ │ ├── local/
│ │ │ ├── AppDatabase.kt
│ │ │ ├── Converters.kt
│ │ │ ├── DatabaseModule.kt
│ │ │ ├── DeviceAppDao.kt
│ │ │ ├── DeviceAppEntity.kt
│ │ │ ├── EvaluationDao.kt
│ │ │ └── EvaluationEntity.kt
│ │ ├── repository/
│ │ │ ├── DeviceAppCacheRepositoryImpl.kt
│ │ │ ├── EvaluationRepositoryImpl.kt
│ │ │ └── InstalledApplicationsRepository.kt
│ │ └── system/
│ │ ├── DeviceConfiguration.kt
│ │ ├── Settings.kt
│ │ └── SystemPropertyReader.kt
│ └── test/
│ └── java/
│ └── com/
│ └── klee/
│ └── sapio/
│ └── data/
│ ├── CachedFdroidAvailabilityCheckerTest.kt
│ ├── ConvertersTest.kt
│ ├── EvaluationRepositoryImplTest.kt
│ └── InstalledApplicationsRepositoryTest.kt
├── detekt.yml
├── domain/
│ ├── build.gradle
│ └── src/
│ ├── main/
│ │ └── java/
│ │ └── com/
│ │ └── klee/
│ │ └── sapio/
│ │ └── domain/
│ │ ├── AppSettings.kt
│ │ ├── CheckFdroidAvailabilityUseCase.kt
│ │ ├── DeviceAppCacheRepository.kt
│ │ ├── DeviceInfo.kt
│ │ ├── EvaluateAppUseCase.kt
│ │ ├── EvaluationRepository.kt
│ │ ├── FdroidAvailabilityChecker.kt
│ │ ├── FetchAppEvaluationUseCase.kt
│ │ ├── FetchIconUrlUseCase.kt
│ │ ├── InstalledApplicationsDataSource.kt
│ │ ├── ListLatestEvaluationsUseCase.kt
│ │ ├── SearchEvaluationUseCase.kt
│ │ └── model/
│ │ ├── CachedDeviceApp.kt
│ │ ├── DeviceProfile.kt
│ │ └── Models.kt
│ └── test/
│ └── java/
│ └── com/
│ └── klee/
│ └── sapio/
│ └── domain/
│ ├── CheckFdroidAvailabilityUseCaseTest.kt
│ ├── EvaluateAppUseCaseTest.kt
│ ├── FetchAppEvaluationUseCaseTest.kt
│ ├── FetchIconUrlUseCaseTest.kt
│ ├── ListLatestEvaluationsUseCaseTest.kt
│ └── SearchEvaluationUseCaseTest.kt
├── fastlane/
│ └── metadata/
│ └── android/
│ ├── de-DE/
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── en-US/
│ │ ├── changelogs/
│ │ │ └── 4.txt
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── es-ES/
│ │ ├── full_description.txt
│ │ ├── short_description.txt
│ │ └── title.txt
│ ├── fr-FR/
│ │ ├── full_description.txt
│ │ └── short_description.txt
│ └── it-IT/
│ ├── full_description.txt
│ ├── short_description.txt
│ └── title.txt
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
└── settings.gradle
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
ko_fi: jnthnkl
================================================
FILE: .github/workflows/android.yml
================================================
name: Android CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and checks with Gradle
run: ./gradlew detekt assembleRelease testRelease --parallel
================================================
FILE: .github/workflows/jekyll-gh-pages.yml
================================================
# Sample workflow for building and deploying a Jekyll site to GitHub Pages
name: Deploy Jekyll with GitHub Pages dependencies preinstalled
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Build job
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
source: ./
destination: ./_site
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
================================================
FILE: .gitignore
================================================
*.iml
.gradle
/local.properties
/.idea/*
.DS_Store
**/build
/captures
.externalNativeBuild
.cxx
local.properties
================================================
FILE: LICENSE
================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
================================================
FILE: README.md
================================================
# Sapio
Sapio is the anagram of Open Source API.
Sapio provides the compatibility of an Android application running on a device without Google Play Services (i.e. deGoogled bare Android Open Source Project (AOSP) devices, coupled or not with microG).
Sapio can serve as a lobbying tool by sharing compatibility on social media to raise awareness among app developers about respecting users' personal data.
Evaluations in Sapio are given to the community by the community.
[](https://f-droid.org/packages/com.klee.sapio/) [](https://github.com/jonathanklee/Sapio/releases)
# Rating
🟢 The app works perfectly without Google Play Services
🟡 The app works partially: at least one feature (notifications, in-app purchases, login methods etc) does not work without Google Play Services
🔴 The app does not work at all or crashes without Google Play Services
**bareAOSP** The device is a bare AOSP device
**microG** The device has microG installed
**secure** The device is considered secured
**unsafe** The device is considered unsafe
# 🔨 Build
## Get the sources
```
git clone git@github.com:jonathanklee/Sapio.git
```
## Build Sapio
```
cd Sapio
./gradlew assembleDebug
````
# 📱 Install
```
adb install ./app/build/outputs/apk/debug/app-debug.apk
```
# 🌍 Public API
## Base url
```
https://server.sapio.ovh/api
```
## Endpoints
### List evaluations
- Endpoint: /sapio-applications
- Method: GET
- Description: List evaluations
- Parameters: https://docs.strapi.io/dev-docs/api/rest/parameters
- Result:
- https://docs.strapi.io/dev-docs/api/rest#requests
- attributes:
- microg: 1 for microG, 2 for bareAOSP
- secure: 3 for secure, 4 for unsafe
- rating: 1 for green, 2 for yellow, 3 for red
- Example: Get the latest 100 evaluations
```
curl -X GET "https://server.sapio.ovh/api/sapio-applications?pagination\[pageSize\]=100&sort=updatedAt:Desc"
```
### Search evaluations
- Endpoint: /sapio-applications
- Method: GET
- Description: Search evaluations
- Parameters: https://docs.strapi.io/dev-docs/api/rest/filters-locale-publication#filtering
- Result:
- https://docs.strapi.io/dev-docs/api/rest#requests
- attributes:
- microg: 1 for microG, 2 for bareAOSP
- secure: 3 for secure, 4 for unsafe
- rating: 1 for green, 2 for yellow, 3 for red
- Example: Search evaluations for an app called ChatGPT
```
curl -X GET "https://server.sapio.ovh/api/sapio-applications?filters\[name\]\[\$eq\]=ChatGPT"
```
### Get icons
- Endpoint: /upload/files
- Method: GET
- Description: Get icons
- Parameters: https://docs.strapi.io/dev-docs/api/rest/parameters
- Example: Get ChatGPT icon
```
curl -X GET "https://server.sapio.ovh/api/upload/files?filters\[name\]\[\$eq\]=com.openai.chatgpt.png"
```
# ⚠️ Disclaimer
Evaluations are community-contributed and may be inaccurate, incomplete, or device-specific. Sapio and its maintainers are not responsible for any issues arising from relying on these evaluations.
# ☕ Coffee
If you want to offer me a coffee for the maintenance of the server part:
# 👏 Credits
Brain icons created by Freepik - FlaticonSearch icons created by Smashicons - Flaticon
================================================
FILE: app/.gitignore
================================================
/build
================================================
FILE: app/build.gradle
================================================
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-parcelize'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
alias libs.plugins.compose.compiler
id 'jacoco'
}
android {
compileSdk 36
defaultConfig {
applicationId "com.klee.sapio"
minSdk 21
targetSdk 35
versionCode 85
versionName "2.2.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
testOptions {
unitTests.returnDefaultValues = true
}
buildFeatures {
viewBinding = true
compose = true
}
namespace 'com.klee.sapio'
}
jacoco {
toolVersion = "0.8.13"
reportsDirectory = layout.buildDirectory.dir("jacocoReports")
}
// Ensure jacoco agent instruments all JVM unit tests (including Robolectric).
tasks.withType(Test).configureEach {
jacoco.includeNoLocationClasses = true
jacoco.excludes += ['jdk.internal.*']
}
tasks.register("jacocoTestReport", JacocoReport) {
dependsOn tasks.test
reports {
xml.required = true
html.required = true
}
def fileFilter = [
'**/R.class',
'**/R$*.class',
'**/BuildConfig.*',
'**/Manifest*.*',
'**/*Test*.*',
'**/android/**/*.*',
'**/androidTest/**/*.*',
'**/test/**/*.*',
'**/*$ViewInjector*.*',
'**/*$ViewBinder*.*',
'**/databinding/**/*.*'
]
// Kotlin and Java classes live in different intermediate folders with AGP 8+.
def kotlinDebugTree = fileTree(
dir: "${project.buildDir}/tmp/kotlin-classes/debug",
excludes: fileFilter
)
def javaDebugTree = fileTree(
dir: "${project.buildDir}/intermediates/javac/debug/compileDebugJavaWithJavac/classes",
excludes: fileFilter
)
def mainSrc = [
"${project.projectDir}/src/main/java",
"${project.projectDir}/src/main/kotlin"
]
sourceDirectories.from = files(mainSrc)
classDirectories.from = files([kotlinDebugTree, javaDebugTree])
executionData.from = fileTree(dir: "${project.buildDir}", includes: ["**/*.exec", "**/*.ec"])
}
tasks.register("jacocoTestCoverageVerification", JacocoCoverageVerification) {
dependsOn tasks.jacocoTestReport
violationRules {
rule {
limit {
minimum = 0.5 // 50% coverage minimum
}
}
}
}
dependencies {
implementation project(':domain')
implementation project(':data')
implementation libs.androidx.core.ktx
implementation libs.androidx.appcompat
implementation libs.material
implementation libs.androidx.constraintlayout
implementation libs.glide
implementation libs.coil.compose
implementation libs.coil.network.okhttp
implementation libs.kotlinx.coroutines.android
implementation libs.androidx.navigation.fragment.ktx
implementation libs.androidx.navigation.ui.ktx
implementation libs.androidx.recyclerview
implementation libs.room.runtime
implementation libs.room.ktx
kapt libs.room.compiler
// hilt
implementation libs.hilt.android
implementation libs.androidx.runner
implementation libs.androidx.material3.android
kapt libs.hilt.compiler
testImplementation libs.hilt.android.testing
implementation libs.androidx.emoji2
implementation libs.androidx.emoji2.views
implementation libs.androidx.emoji2.views.helper
implementation libs.rootbeer.lib
testImplementation libs.junit
testImplementation libs.mockito.core
testImplementation libs.mockito.inline
testImplementation libs.robolectric
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.androidx.arch.core.testing
androidTestImplementation libs.mockito.android
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
// retrofit
implementation libs.retrofit
implementation libs.converter.jackson
implementation libs.logging.interceptor
implementation libs.retrofit2.kotlin.coroutines.adapter
implementation libs.circleimageview
implementation libs.androidx.swiperefreshlayout
// splashscreen
implementation libs.androidx.core.splashscreen
// preferences
implementation libs.androidx.preference.ktx
// workmanager
implementation libs.androidx.work.runtime.ktx
// lottie
implementation libs.lottie
// compose
implementation platform(libs.androidx.compose.bom)
implementation(libs.androidx.foundation)
implementation libs.androidx.ui
implementation libs.androidx.ui.tooling.preview
implementation libs.androidx.activity.compose
debugImplementation libs.androidx.ui.tooling
}
================================================
FILE: app/detekt-baseline.xml
================================================
LoopWithTooManyJumpStatements:DeviceConfiguration.kt$DeviceConfiguration$forTooManyFunctions:EvaluationRepository.kt$EvaluationRepositoryTooManyFunctions:EvaluationRepositoryImpl.kt$EvaluationRepositoryImpl : EvaluationRepository
================================================
FILE: app/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
================================================
FILE: app/src/androidTest/java/com/klee/sapio/ExampleInstrumentedTest.kt
================================================
package com.klee.sapio
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import junit.framework.TestCase.assertEquals
import org.junit.runner.RunWith
import org.junit.Test
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.klee.sapio", appContext.packageName)
}
}
================================================
FILE: app/src/main/AndroidManifest.xml
================================================
================================================
FILE: app/src/main/java/com/klee/sapio/SapioApplication.kt
================================================
package com.klee.sapio
import android.app.Application
import com.google.android.material.color.DynamicColors
import com.klee.sapio.work.CompatibilityCheckScheduler
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class SapioApplication : Application() {
override fun onCreate() {
super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this)
CompatibilityCheckScheduler.schedule(this)
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/model/InstalledAppWithRating.kt
================================================
package com.klee.sapio.ui.model
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.domain.model.InstalledApplication
data class InstalledAppWithRating(
val installedApp: InstalledApplication,
val evaluation: Evaluation?
)
================================================
FILE: app/src/main/java/com/klee/sapio/ui/model/Label.kt
================================================
package com.klee.sapio.ui.model
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import com.klee.sapio.R
import com.klee.sapio.domain.model.GmsType
import com.klee.sapio.domain.model.UserType
data class Label(val text: String, val color: Int) {
companion object {
const val MICROG = GmsType.MICROG
const val BARE_AOSP = GmsType.BARE_AOSP
const val SECURE = UserType.SECURE
const val UNSAFE = UserType.UNSAFE
@RequiresApi(Build.VERSION_CODES.M)
fun create(context: Context, label: Int): Label {
return when (label) {
MICROG -> Label(
context.getString(R.string.microg_label),
context.getColor(R.color.blue_200)
)
BARE_AOSP -> Label(
context.getString(R.string.bare_aosp_label),
context.getColor(R.color.blue_700)
)
SECURE -> Label(
context.getString(R.string.secure_label),
context.getColor(R.color.purple_200)
)
UNSAFE -> Label(
context.getString(R.string.unsafe_label),
context.getColor(R.color.purple_700)
)
else -> Label(" Empty label ", context.getColor(R.color.black))
}
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/model/Rating.kt
================================================
package com.klee.sapio.ui.model
import com.klee.sapio.R
data class Rating(val value: Int, val drawable: Int, val text: String) {
companion object {
const val GOOD = 1
const val AVERAGE = 2
const val BAD = 3
const val GREEN_CIRCLE_EMOJI = 0x1F7E2
const val YELLOW_CIRCLE_EMOJI = 0x1F7E1
const val RED_CIRCLE_EMOJI = 0x1F534
fun create(rating: Int): Rating {
return when (rating) {
GOOD -> Rating(GOOD, R.drawable.ic_status_green, String(Character.toChars(GREEN_CIRCLE_EMOJI)))
AVERAGE -> Rating(AVERAGE, R.drawable.ic_status_yellow, String(Character.toChars(YELLOW_CIRCLE_EMOJI)))
BAD -> Rating(BAD, R.drawable.ic_status_red, String(Character.toChars(RED_CIRCLE_EMOJI)))
else -> Rating(BAD, R.drawable.ic_status_red, String(Character.toChars(RED_CIRCLE_EMOJI)))
}
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/model/SharedEvaluation.kt
================================================
package com.klee.sapio.ui.model
import android.graphics.Bitmap
data class SharedEvaluation(
val name: String,
val packageName: String,
val icon: Bitmap,
val ratingMicrog: Int,
val ratingBareAOSP: Int
)
================================================
FILE: app/src/main/java/com/klee/sapio/ui/state/AppEvaluationsUiState.kt
================================================
package com.klee.sapio.ui.state
import com.klee.sapio.domain.model.Evaluation
data class AppEvaluationsUiState(
val microgUser: Evaluation? = null,
val microgRoot: Evaluation? = null,
val bareAospUser: Evaluation? = null,
val bareAospRoot: Evaluation? = null,
val iconUrl: String? = null,
val pendingCount: Int = 0,
val hasError: Boolean = false
) {
val isFullyLoaded: Boolean get() = pendingCount == 0
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/state/ChooseAppUiState.kt
================================================
package com.klee.sapio.ui.state
import com.klee.sapio.domain.model.InstalledApplication
data class ChooseAppUiState(
val apps: List = emptyList(),
val isLoading: Boolean = true
)
================================================
FILE: app/src/main/java/com/klee/sapio/ui/state/EvaluateUiState.kt
================================================
package com.klee.sapio.ui.state
data class EvaluateUiState(
val gmsType: Int,
val userType: Int
)
sealed class EvaluateEvent {
data class NavigateToSuccess(val packageName: String, val appName: String) : EvaluateEvent()
object ShowError : EvaluateEvent()
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/state/FeedUiState.kt
================================================
package com.klee.sapio.ui.state
import com.klee.sapio.domain.model.Evaluation
data class FeedUiState(
val items: List = emptyList(),
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val hasError: Boolean = false
)
================================================
FILE: app/src/main/java/com/klee/sapio/ui/state/MyAppsUiState.kt
================================================
package com.klee.sapio.ui.state
import com.klee.sapio.ui.model.InstalledAppWithRating
data class MyAppsUiState(
val items: List = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val progress: Int = 0
)
================================================
FILE: app/src/main/java/com/klee/sapio/ui/state/SearchUiState.kt
================================================
package com.klee.sapio.ui.state
import com.klee.sapio.domain.model.Evaluation
data class SearchUiState(
val query: String = "",
val items: List = emptyList(),
val isLoading: Boolean = false,
val hasError: Boolean = false
)
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/AboutFragment.kt
================================================
package com.klee.sapio.ui.view
import android.os.Build
import android.os.Bundle
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.klee.sapio.BuildConfig
import com.klee.sapio.R
import com.klee.sapio.databinding.FragmentAboutBinding
class AboutFragment : Fragment() {
companion object {
const val RATING_RULES = "https://github.com/jonathanklee/Sapio?tab=readme-ov-file#rating"
const val GITHUB_URL = "https://github.com/jonathanklee/Sapio"
}
private var _binding: FragmentAboutBinding? = null
private val mBinding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentAboutBinding.inflate(inflater, container, false)
return mBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mBinding.version.text = "v${BuildConfig.VERSION_NAME}"
mBinding.ratingRules.text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Html.fromHtml(getString(R.string.rating_rules, RATING_RULES), Html.FROM_HTML_MODE_COMPACT)
} else {
@Suppress("DEPRECATION")
Html.fromHtml(getString(R.string.rating_rules, RATING_RULES))
}
mBinding.ratingRules.movementMethod = LinkMovementMethod.getInstance()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/ChooseAppAdapter.kt
================================================
package com.klee.sapio.ui.view
import android.content.pm.PackageManager
import android.os.Build
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.klee.sapio.databinding.ChooseAppCardBinding
import com.klee.sapio.domain.model.InstalledApplication
class ChooseAppAdapter(
private val onAppClicked: (InstalledApplication) -> Unit
) : ListAdapter(DIFF_CALLBACK) {
inner class ViewHolder(val binding: ChooseAppCardBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(app: InstalledApplication) {
binding.appName.text = app.name
try {
val pm = binding.root.context.packageManager
val appInfo = pm.getApplicationInfo(app.packageName, 0)
val icon = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
appInfo.loadUnbadgedIcon(pm)
} else {
appInfo.loadIcon(pm)
}
binding.appIcon.setImageDrawable(icon)
} catch (e: PackageManager.NameNotFoundException) {
// leave default icon
}
binding.root.setOnClickListener { onAppClicked(app) }
binding.appIcon.setOnClickListener { onAppClicked(app) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ChooseAppCardBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() {
override fun areItemsTheSame(old: InstalledApplication, new: InstalledApplication) =
old.packageName == new.packageName
override fun areContentsTheSame(old: InstalledApplication, new: InstalledApplication) =
old == new
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/ChooseAppDialog.kt
================================================
package com.klee.sapio.ui.view
import android.content.DialogInterface
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.klee.sapio.databinding.DialogChooseAppBinding
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.ui.state.ChooseAppUiState
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class ChooseAppDialog(
private val uiState: StateFlow,
private val onAppSelected: (InstalledApplication) -> Unit,
private val onDismissed: (() -> Unit)? = null
) : DialogFragment() {
private lateinit var mBinding: DialogChooseAppBinding
private var hasSelection = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mBinding = DialogChooseAppBinding.inflate(layoutInflater)
return mBinding.root
}
override fun onStart() {
super.onStart()
val width = (resources.displayMetrics.widthPixels * DIALOG_WIDTH_RATIO).toInt()
dialog?.window?.setLayout(width, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
val recyclerView = mBinding.recyclerView
recyclerView.layoutManager = LinearLayoutManager(requireActivity(), RecyclerView.VERTICAL, false)
val adapter = ChooseAppAdapter { app ->
hasSelection = true
dismiss()
onAppSelected(app)
}
recyclerView.adapter = adapter
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
uiState.collect { state ->
if (state.apps.isNotEmpty()) {
mBinding.progressBar.visibility = View.GONE
recyclerView.visibility = View.VISIBLE
}
adapter.submitList(state.apps)
}
}
}
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
if (!hasSelection) {
onDismissed?.invoke()
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (!hasSelection) {
onDismissed?.invoke()
}
}
companion object {
private const val DIALOG_WIDTH_RATIO = 0.75
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/ChooseAppFragment.kt
================================================
package com.klee.sapio.ui.view
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.klee.sapio.R
import com.klee.sapio.databinding.FragmentChooseAppBinding
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.ui.viewmodel.ChooseAppViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class ChooseAppFragment : Fragment() {
private lateinit var mBinding: FragmentChooseAppBinding
private var mApp: InstalledApplication? = null
val viewModel: ChooseAppViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mBinding = FragmentChooseAppBinding.inflate(inflater, container, false)
mBinding.chooseAppButton.setOnClickListener { onChooseButtonClicked() }
mBinding.nextButton.isEnabled = false
mBinding.nextButton.setOnClickListener { onNextButtonClicked() }
mBinding.backButton.setOnClickListener {
findNavController().navigate(R.id.action_chooseAppFragment_to_warningFragment)
}
return mBinding.root
}
private fun onChooseButtonClicked() {
mBinding.chooseAppButton.isEnabled = false
mBinding.nextButton.isEnabled = false
val dialog = ChooseAppDialog(
uiState = viewModel.uiState,
onAppSelected = { chosenApp ->
mBinding.appName.text = chosenApp.name
mApp = chosenApp
mBinding.nextButton.isEnabled = true
mBinding.chooseAppButton.isEnabled = true
},
onDismissed = {
mBinding.chooseAppButton.isEnabled = true
}
)
dialog.show(parentFragmentManager, "")
}
private fun onNextButtonClicked() {
val bundle = bundleOf(
"package" to mApp?.packageName,
"name" to mApp?.name
)
findNavController().navigate(R.id.action_chooseAppFragment_to_evaluateFragment, bundle)
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/Color.kt
================================================
package com.klee.sapio.ui.view
import androidx.compose.ui.graphics.Color
val Blue200 = Color(0xFF90CAF9)
val Blue700 = Color(0xFF1976D2)
val Gray = Color(0xFF212121)
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/ContributeFragment.kt
================================================
package com.klee.sapio.ui.view
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.klee.sapio.databinding.FragmentContributeBinding
class ContributeFragment : Fragment() {
private lateinit var mBinding: FragmentContributeBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mBinding = FragmentContributeBinding.inflate(inflater, container, false)
return mBinding.root
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/EvaluateFragment.kt
================================================
package com.klee.sapio.ui.view
import android.content.res.ColorStateList
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.RadioButton
import androidx.annotation.RequiresApi
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.klee.sapio.R
import com.klee.sapio.databinding.FragmentEvaluateBinding
import com.klee.sapio.ui.model.Label
import com.klee.sapio.ui.model.Rating
import com.klee.sapio.ui.viewmodel.EvaluateViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class EvaluateFragment : Fragment() {
companion object {
const val NOT_EXISTING = -1
}
private val mViewModel by viewModels()
private lateinit var mBinding: FragmentEvaluateBinding
@RequiresApi(Build.VERSION_CODES.M)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mBinding = FragmentEvaluateBinding.inflate(inflater, container, false)
val packageName = arguments?.getString("package").orEmpty()
val appName = arguments?.getString("name").orEmpty()
val state = mViewModel.uiState.value
val microgLabel = Label.create(requireContext(), state.gmsType)
mBinding.microgConfiguration.text = microgLabel.text
mBinding.microgConfiguration.backgroundTintList = ColorStateList.valueOf(microgLabel.color)
val isRootedLabel = Label.create(requireContext(), state.userType)
mBinding.secureConfiguration.text = isRootedLabel.text
mBinding.secureConfiguration.backgroundTintList = ColorStateList.valueOf(isRootedLabel.color)
mBinding.validateButton.isEnabled = false
mBinding.note.setOnCheckedChangeListener { _, _ ->
updateButtonState()
}
mBinding.validateButton.setOnClickListener {
val rating = getRatingFromRadioId(mBinding.note.checkedRadioButtonId, requireView())
val bundle = bundleOf("package" to packageName, "name" to appName, "rating" to rating)
findNavController().navigate(R.id.action_evaluateFragment_to_loadingFragment, bundle)
}
mBinding.backButton.setOnClickListener {
findNavController().navigate(R.id.action_evaluateFragment_to_chooseAppFragment)
}
return mBinding.root
}
private fun updateButtonState() {
val radioSelected = mBinding.note.checkedRadioButtonId != -1
mBinding.validateButton.isEnabled = radioSelected
}
private fun getRatingFromRadioId(id: Int, view: View): Int {
val radioButton: RadioButton = view.findViewById(id)
return when (radioButton.text) {
getString(R.string.works_perfectly) -> Rating.GOOD
getString(R.string.works_partially) -> Rating.AVERAGE
getString(R.string.dont_work) -> Rating.BAD
else -> 0
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/EvaluationsFragment.kt
================================================
package com.klee.sapio.ui.view
import android.app.NotificationManager
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore.Images.Media
import android.util.Log
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.core.graphics.createBitmap
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.Target
import com.bumptech.glide.request.transition.Transition
import com.klee.sapio.R
import com.klee.sapio.databinding.FragmentEvaluationsBinding
import com.klee.sapio.domain.AppSettings
import com.klee.sapio.ui.model.Rating
import com.klee.sapio.ui.model.SharedEvaluation
import com.klee.sapio.ui.viewmodel.AppEvaluationsViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.IOException
import java.text.DateFormat
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@AndroidEntryPoint
class EvaluationsFragment : Fragment() {
@Inject
lateinit var settings: AppSettings
private var _binding: FragmentEvaluationsBinding? = null
private val mBinding get() = _binding!!
private val mViewModel by activityViewModels()
private lateinit var shareLauncher: ActivityResultLauncher
private var shareImage: Uri? = null
private var iconReady = false
companion object {
const val TAG = "EvaluationsFragment"
const val COMPRESSION_QUALITY = 100
const val SCREENSHOT_WIDTH_DP = 200
const val SCREENSHOT_HEIGHT_DP = 115
private const val ARG_PACKAGE_NAME = "packageName"
private const val ARG_APP_NAME = "appName"
private const val ARG_SHARE_IMMEDIATELY = "shareImmediately"
private const val ARG_NOTIFICATION_ID = "notificationId"
fun newInstance(
packageName: String,
appName: String,
shareImmediately: Boolean = false,
notificationId: Int = -1
) = EvaluationsFragment().apply {
arguments = Bundle().apply {
putString(ARG_PACKAGE_NAME, packageName)
putString(ARG_APP_NAME, appName)
putBoolean(ARG_SHARE_IMMEDIATELY, shareImmediately)
putInt(ARG_NOTIFICATION_ID, notificationId)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
shareLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
shareImage?.let { requireContext().contentResolver.delete(it, null, null) }
shareImage = null
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentEvaluationsBinding.inflate(inflater, container, false)
return mBinding.root
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val packageName = arguments?.getString(ARG_PACKAGE_NAME).orEmpty()
val appName = arguments?.getString(ARG_APP_NAME).orEmpty()
val shareImmediately = arguments?.getBoolean(ARG_SHARE_IMMEDIATELY) ?: false
val notificationId = arguments?.getInt(ARG_NOTIFICATION_ID) ?: -1
mBinding.packageName.text = packageName
mBinding.applicationName.text = appName
mBinding.shareButton.setOnClickListener {
startTakingScreenshot(appName, packageName)
}
mBinding.infoIcon.setOnClickListener {
(requireActivity() as MainActivity).navigateToAbout()
}
hideCard()
if (shareImmediately) {
if (notificationId != -1) {
val notificationManager =
requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notificationId)
}
onElementsLoaded {
startTakingScreenshot(appName, packageName)
}
}
onElementsLoaded {
showCard()
}
handleUnsafeConfigurationSetting()
observeEvaluations()
}
private fun hideCard() {
mBinding.card.visibility = View.INVISIBLE
mBinding.progressBar.visibility = View.VISIBLE
}
private fun showCard() {
mBinding.progressBar.visibility = View.GONE
mBinding.card.visibility = View.VISIBLE
}
private fun onElementsLoaded(callback: () -> Unit) {
mViewModel.uiState
.filter { it.isFullyLoaded }
.onEach { callback.invoke() }
.launchIn(viewLifecycleOwner.lifecycleScope)
}
private fun handleUnsafeConfigurationSetting() {
val shouldShow = settings.isUnsafeConfigurationEnabled()
with(mBinding) {
secure.isVisible = shouldShow
microgRoot.isVisible = shouldShow
bareAospRoot.isVisible = shouldShow
empty.isVisible = shouldShow
unsafe.isVisible = shouldShow
if (shouldShow) {
val extraPadding = resources.getDimensionPixelSize(R.dimen.card_unsafe_extra_padding)
cardContent.setPadding(extraPadding, 0, extraPadding, 0)
}
}
}
private fun observeEvaluations() {
viewLifecycleOwner.lifecycleScope.launch {
mViewModel.uiState.collect { state ->
renderEvaluation(mBinding.microgUser, state.microgUser)
renderEvaluation(mBinding.bareAospUser, state.bareAospUser)
if (settings.isUnsafeConfigurationEnabled()) {
renderEvaluation(mBinding.bareAospRoot, state.bareAospRoot)
renderEvaluation(mBinding.microgRoot, state.microgRoot)
}
if (state.isFullyLoaded) {
val unsafeEnabled = settings.isUnsafeConfigurationEnabled()
val microgHasData = state.microgUser != null || (unsafeEnabled && state.microgRoot != null)
val bareAospHasData = state.bareAospUser != null || (unsafeEnabled && state.bareAospRoot != null)
mBinding.microgRow.isVisible = microgHasData
mBinding.bareAospRow.isVisible = bareAospHasData
mBinding.shareButton.isEnabled = state.microgUser != null || state.bareAospUser != null
}
if (state.iconUrl != null && !iconReady) {
iconReady = true
val needsCount = !state.isFullyLoaded
if (state.iconUrl.isNotEmpty()) {
Glide.with(requireContext().applicationContext)
.load(state.iconUrl)
.listener(object : RequestListener {
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target?,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
if (needsCount) mViewModel.onIconDisplayed()
return false
}
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target,
isFirstResource: Boolean
): Boolean {
if (needsCount) mViewModel.onIconDisplayed()
return false
}
})
.into(mBinding.image)
} else {
if (needsCount) mViewModel.onIconDisplayed()
}
}
}
}
}
private fun renderEvaluation(
imageView: android.widget.ImageView,
evaluation: com.klee.sapio.domain.model.Evaluation?
) {
if (evaluation != null) {
imageView.setImageResource(Rating.create(evaluation.rating).drawable)
imageView.visibility = View.VISIBLE
} else {
imageView.setImageDrawable(null)
imageView.visibility = View.INVISIBLE
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
imageView.tooltipText = evaluation?.let {
computeTooltip(it)
}
}
}
private fun computeTooltip(evaluation: com.klee.sapio.domain.model.Evaluation): String {
val ratingText = when (evaluation.rating) {
Rating.GOOD -> getString(R.string.good)
Rating.AVERAGE -> getString(R.string.average)
Rating.BAD -> getString(R.string.bad)
else -> getString(R.string.unknown)
}
val date = evaluation.updatedAt?.let { DateFormat.getDateInstance().format(it) }
return if (date == null) ratingText else "$date - $ratingText"
}
private fun startTakingScreenshot(appName: String, packageName: String) {
viewLifecycleOwner.lifecycleScope.launch {
val state = mViewModel.uiState.value
val icon = saveImageToFile(
requireContext(),
state.iconUrl.orEmpty()
)
val sharedEvaluation = SharedEvaluation(
appName,
packageName,
icon,
state.microgUser?.rating ?: 0,
state.bareAospUser?.rating ?: 0,
)
share(takeScreenshot(sharedEvaluation), appName)
}
}
private fun takeScreenshot(sharedEvaluation: SharedEvaluation): Bitmap {
return composeToBitmap(requireContext(), SCREENSHOT_WIDTH_DP, SCREENSHOT_HEIGHT_DP) {
ShareScreenshot(sharedEvaluation)
}
}
private suspend fun saveImageToFile(
context: Context,
url: String
): Bitmap = suspendCancellableCoroutine { continuation ->
val target = object : CustomTarget() {
override fun onResourceReady(
resource: Bitmap,
transition: Transition?
) {
continuation.resume(resource)
}
override fun onLoadCleared(placeholder: Drawable?) {
continuation.resumeWithException(Exception("Failed to load image"))
}
}
Glide.with(requireContext().applicationContext)
.asBitmap()
.load(url)
.into(target)
continuation.invokeOnCancellation {
Glide.with(context).clear(target)
}
}
private fun composeToBitmap(
context: Context,
widthDp: Int,
heightDp: Int,
scaleFactor: Float = 2f,
composable: @Composable () -> Unit,
): Bitmap {
val displayMetrics = context.resources.displayMetrics
val widthPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
widthDp.toFloat(),
displayMetrics
).toInt()
val heightPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
heightDp.toFloat(),
displayMetrics
).toInt()
val composeView = ComposeView(context).apply {
setLayerType(View.LAYER_TYPE_SOFTWARE, null)
setContent { composable() }
layoutParams = ViewGroup.LayoutParams(widthPx, heightPx)
}
mBinding.bitmapContainer.addView(composeView)
composeView.measure(
View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(heightPx, View.MeasureSpec.EXACTLY)
)
composeView.layout(0, 0, composeView.measuredWidth, composeView.measuredHeight)
val bitmap = createBitmap(
(composeView.width * scaleFactor).toInt(),
(composeView.height * scaleFactor).toInt()
)
val canvas = Canvas(bitmap)
canvas.scale(scaleFactor, scaleFactor)
composeView.draw(canvas)
mBinding.bitmapContainer.removeView(composeView)
return bitmap
}
private fun share(bitmap: Bitmap, appName: String) {
val contentValues = ContentValues().apply {
put(Media.DISPLAY_NAME, "screenshot_${System.currentTimeMillis()}")
put(Media.DESCRIPTION, getString(R.string.share_android_compatibility, appName))
put(Media.MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(
Media.RELATIVE_PATH,
"${Environment.DIRECTORY_PICTURES}/${Environment.DIRECTORY_SCREENSHOTS}"
)
}
}
shareImage =
requireContext().contentResolver.insert(Media.EXTERNAL_CONTENT_URI, contentValues)
?: return
try {
requireContext().contentResolver.openOutputStream(shareImage!!)?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, COMPRESSION_QUALITY, outputStream)
}
} catch (exception: IOException) {
Log.e(TAG, "Failed to share matrix", exception)
}
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "image/*"
putExtra(Intent.EXTRA_STREAM, shareImage)
putExtra(
Intent.EXTRA_TEXT,
getString(R.string.share_android_compatibility_text, appName)
)
}
shareLauncher.launch(Intent.createChooser(shareIntent, "Share"))
}
override fun onDestroyView() {
super.onDestroyView()
iconReady = false
shareImage?.let {
requireContext().contentResolver.delete(it, null, null)
shareImage = null
}
_binding = null
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/FeedAppAdapter.kt
================================================
package com.klee.sapio.ui.view
import android.content.Context
import android.content.res.ColorStateList
import android.os.Build
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.klee.sapio.R
import com.klee.sapio.databinding.FeedAppCardBinding
import com.klee.sapio.domain.AppSettings
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.domain.model.UserType
import com.klee.sapio.ui.model.Label
import com.klee.sapio.ui.model.Rating
import java.text.SimpleDateFormat
import java.util.Locale
class FeedAppAdapter(
private val mContext: Context,
private var mSettings: AppSettings,
private val onAppSelected: (packageName: String, appName: String) -> Unit
) : ListAdapter(DiffCallback) {
companion object {
const val DATE_FORMAT = "dd/MM/yyyy"
private val DiffCallback = object : DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: Evaluation, newItem: Evaluation): Boolean {
return oldItem.packageName == newItem.packageName &&
oldItem.microg == newItem.microg &&
oldItem.secure == newItem.secure
}
override fun areContentsTheSame(oldItem: Evaluation, newItem: Evaluation): Boolean {
return oldItem == newItem
}
}
}
inner class ViewHolder(val binding: FeedAppCardBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = FeedAppCardBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val app = getItem(position)
val element = holder.binding
element.appName.text = app.name
element.packageName.text = app.packageName
val dateFormat = SimpleDateFormat(DATE_FORMAT, Locale.getDefault())
element.updatedDate.text =
mContext.getString(
R.string.updated_on,
app.updatedAt?.let { dateFormat.format(it) }
)
element.emoji.setImageResource(Rating.create(app.rating).drawable)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val microgLabel = Label.create(mContext, app.microg)
val secureLabel = Label.create(mContext, app.secure)
element.microG.text = microgLabel.text
element.microG.backgroundTintList = ColorStateList.valueOf(microgLabel.color)
element.secure.text = secureLabel.text
element.secure.backgroundTintList = ColorStateList.valueOf(secureLabel.color)
if (mSettings.getUnsafeConfigurationLevel() == UserType.UNSAFE) {
element.secure.visibility = View.VISIBLE
} else {
element.secure.visibility = View.GONE
}
}
Glide.with(mContext.applicationContext).clear(holder.binding.image)
val iconUrl = app.iconUrl
if (!iconUrl.isNullOrEmpty()) {
Glide.with(mContext.applicationContext)
.load(iconUrl)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(holder.binding.image)
}
holder.itemView.setOnClickListener {
onAppSelected(app.packageName, app.name)
}
}
override fun onViewRecycled(holder: ViewHolder) {
super.onViewRecycled(holder)
Glide.with(mContext.applicationContext).clear(holder.binding.image)
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/FeedFragment.kt
================================================
package com.klee.sapio.ui.view
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.klee.sapio.databinding.FragmentMainBinding
import com.klee.sapio.domain.AppSettings
import com.klee.sapio.ui.viewmodel.FeedViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class FeedFragment : Fragment() {
companion object {
private const val LOAD_MORE_THRESHOLD = 3
}
@Inject
lateinit var mSettings: AppSettings
private lateinit var mBinding: FragmentMainBinding
private lateinit var mFeedAppAdapter: FeedAppAdapter
private val mViewModel by activityViewModels()
private var fetchJob: Job? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mBinding = FragmentMainBinding.inflate(inflater, container, false)
mBinding.recyclerView.layoutManager = LinearLayoutManager(context)
setupAdapter()
collectFeed()
mBinding.refreshView.setOnRefreshListener {
mViewModel.refresh()
}
setupScrollListener()
return mBinding.root
}
override fun onResume() {
super.onResume()
mViewModel.syncUnsafeConfiguration(mSettings.getUnsafeConfigurationLevel())
}
private fun setupAdapter() {
mFeedAppAdapter = FeedAppAdapter(requireContext(), mSettings) { packageName, appName ->
(requireActivity() as MainActivity).navigateToEvaluations(packageName, appName)
}
mBinding.recyclerView.adapter = mFeedAppAdapter
}
private fun setupScrollListener() {
mBinding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0) return
val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
val lastVisible = layoutManager.findLastVisibleItemPosition()
val total = layoutManager.itemCount
if (total > 0 && lastVisible >= total - LOAD_MORE_THRESHOLD) {
mViewModel.loadNextPage()
}
}
})
}
private fun collectFeed() {
fetchJob?.cancel()
fetchJob = viewLifecycleOwner.lifecycleScope.launch {
mViewModel.uiState.collect { state ->
mFeedAppAdapter.submitList(state.items) {
if (!state.isLoading && !state.isLoadingMore) {
loadMoreIfNeeded()
}
}
val isInitialLoad = state.isLoading && state.items.isEmpty()
mBinding.progressBar.visibility = if (isInitialLoad) View.VISIBLE else View.GONE
mBinding.refreshView.isRefreshing = false
}
}
}
private fun loadMoreIfNeeded() {
mBinding.recyclerView.post {
val layoutManager = mBinding.recyclerView.layoutManager as? LinearLayoutManager ?: return@post
val lastVisible = layoutManager.findLastVisibleItemPosition()
val total = layoutManager.itemCount
if (total > 0 && lastVisible >= total - LOAD_MORE_THRESHOLD) {
mViewModel.loadNextPage()
}
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/FragmentAdapter.kt
================================================
package com.klee.sapio.ui.view
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter
class FragmentAdapter(fragmentManager: FragmentManager, lifecycle: Lifecycle) :
FragmentStateAdapter(fragmentManager, lifecycle) {
private val fragments: HashMap = hashMapOf(
0 to FeedFragment(),
1 to SearchFragment(),
2 to ContributeFragment()
)
override fun getItemCount(): Int {
return fragments.size
}
override fun createFragment(position: Int): Fragment {
return fragments[position]!!
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/LoadingFragment.kt
================================================
package com.klee.sapio.ui.view
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.klee.sapio.R
import com.klee.sapio.databinding.FragmentLoadingBinding
import com.klee.sapio.ui.state.EvaluateEvent
import com.klee.sapio.ui.viewmodel.LoadingViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class LoadingFragment : Fragment() {
private val mViewModel by viewModels()
private lateinit var mBinding: FragmentLoadingBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mBinding = FragmentLoadingBinding.inflate(inflater, container, false)
val packageName = arguments?.getString("package").orEmpty()
val appName = arguments?.getString("name").orEmpty()
val rating = arguments?.getInt("rating") ?: 0
mViewModel.submit(packageName, appName, rating)
viewLifecycleOwner.lifecycleScope.launch {
mViewModel.events.collect { event ->
when (event) {
is EvaluateEvent.NavigateToSuccess -> {
val bundle = Bundle().apply {
putString("package", event.packageName)
putString("name", event.appName)
}
findNavController().navigate(R.id.action_loadingFragment_to_successFragment, bundle)
}
is EvaluateEvent.ShowError -> {
Toast.makeText(context, getString(R.string.upload_error), Toast.LENGTH_LONG).show()
findNavController().popBackStack()
}
}
}
}
return mBinding.root
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/MainActivity.kt
================================================
package com.klee.sapio.ui.view
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.activity.viewModels
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import com.klee.sapio.R
import com.klee.sapio.databinding.ActivityMainBinding
import com.klee.sapio.ui.viewmodel.AppEvaluationsViewModel
import com.klee.sapio.ui.viewmodel.ChooseAppViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var mBinding: ActivityMainBinding
private val mEvaluationsViewModel by viewModels()
private val mChooseAppViewModel by viewModels()
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
companion object {
const val DONATE_URL = "https://ko-fi.com/jnthnkl"
const val EXTRA_PACKAGE_NAME = "packageName"
const val EXTRA_APP_NAME = "appName"
const val EXTRA_SHARE_IMMEDIATELY = "shareImmediately"
const val EXTRA_NOTIFICATION_ID = "notificationId"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(mBinding.root)
requestNotificationPermissionIfNeeded()
mChooseAppViewModel.uiState
if (savedInstanceState == null) {
displayFragment(FeedFragment())
handleDeepLinkIntent(intent)
}
handleEdgeToEdgeInsets()
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
} else if (mBinding.bottomNavigation.selectedItemId != R.id.feed) {
mBinding.bottomNavigation.selectedItemId = R.id.feed
} else {
finish()
}
}
}
)
mBinding.bottomNavigation.setOnItemSelectedListener { item ->
when (item.itemId) {
R.id.feed -> displayFragment(FeedFragment())
R.id.search -> displayFragment(SearchFragment())
R.id.my_apps -> displayFragment(MyAppsFragment())
R.id.contribute -> displayFragment(ContributeFragment())
R.id.options -> displayFragment(PreferencesFragment())
}
return@setOnItemSelectedListener true
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLinkIntent(intent)
}
private fun handleDeepLinkIntent(intent: Intent) {
val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: return
val appName = intent.getStringExtra(EXTRA_APP_NAME).orEmpty()
val shareImmediately = intent.getBooleanExtra(EXTRA_SHARE_IMMEDIATELY, false)
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
navigateToEvaluations(packageName, appName, shareImmediately, notificationId)
}
fun navigateToAbout() {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, AboutFragment())
.addToBackStack(null)
.commit()
}
fun navigateToContribute() {
mBinding.bottomNavigation.selectedItemId = R.id.contribute
}
fun navigateToEvaluations(
packageName: String,
appName: String,
shareImmediately: Boolean = false,
notificationId: Int = -1
) {
mEvaluationsViewModel.listEvaluations(packageName)
val fragment = EvaluationsFragment.newInstance(packageName, appName, shareImmediately, notificationId)
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, fragment)
.addToBackStack(null)
.commit()
}
private fun displayFragment(fragment: Fragment) {
supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
supportFragmentManager.beginTransaction().replace(R.id.fragment_container, fragment).commit()
}
private fun handleEdgeToEdgeInsets() {
ViewCompat.setOnApplyWindowInsetsListener(mBinding.root) { v, windowInsets ->
val bars = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars()
or WindowInsetsCompat.Type.displayCutout()
)
val ime = windowInsets.getInsets(WindowInsetsCompat.Type.ime())
v.updateLayoutParams {
bottomMargin = maxOf(bars.bottom, ime.bottom)
leftMargin = bars.left
rightMargin = bars.right
topMargin = bars.top
}
WindowInsetsCompat.CONSUMED
}
}
private fun requestNotificationPermissionIfNeeded() {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
return
}
val permission = Manifest.permission.POST_NOTIFICATIONS
val isGranted = ContextCompat.checkSelfPermission(this, permission) ==
PackageManager.PERMISSION_GRANTED
if (!isGranted) {
notificationPermissionLauncher.launch(permission)
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/MyAppsAdapter.kt
================================================
package com.klee.sapio.ui.view
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.klee.sapio.databinding.MyAppCardBinding
import com.klee.sapio.ui.model.InstalledAppWithRating
import com.klee.sapio.ui.model.Rating
class MyAppsAdapter(
private val mContext: Context,
private val onAppSelected: (packageName: String, appName: String) -> Unit,
private val onContribute: () -> Unit
) : ListAdapter(DiffCallback) {
companion object {
private val DiffCallback = object : DiffUtil.ItemCallback() {
override fun areItemsTheSame(
oldItem: InstalledAppWithRating,
newItem: InstalledAppWithRating
): Boolean {
return oldItem.installedApp.packageName == newItem.installedApp.packageName
}
override fun areContentsTheSame(
oldItem: InstalledAppWithRating,
newItem: InstalledAppWithRating
): Boolean {
return oldItem == newItem
}
}
}
inner class ViewHolder(val binding: MyAppCardBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = MyAppCardBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)
val element = holder.binding
element.appName.text = item.installedApp.name
element.packageName.text = item.installedApp.packageName
try {
val pm = holder.itemView.context.packageManager
val appInfo = pm.getApplicationInfo(item.installedApp.packageName, 0)
val icon = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
appInfo.loadUnbadgedIcon(pm)
} else {
appInfo.loadIcon(pm)
}
element.image.setImageDrawable(icon)
} catch (e: PackageManager.NameNotFoundException) {
// leave default
}
val rating = item.evaluation?.rating
element.infoIcon.visibility = View.VISIBLE
if (rating != null) {
element.emoji.setImageResource(Rating.create(rating).drawable)
element.emoji.visibility = View.VISIBLE
element.noRatingIcon.visibility = View.GONE
} else {
element.emoji.visibility = View.GONE
element.noRatingIcon.visibility = View.VISIBLE
}
holder.itemView.setOnClickListener {
if (item.evaluation != null) {
onAppSelected(item.installedApp.packageName, item.installedApp.name)
} else {
onContribute()
}
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/MyAppsFragment.kt
================================================
package com.klee.sapio.ui.view
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.klee.sapio.databinding.FragmentMyAppsBinding
import com.klee.sapio.ui.viewmodel.MyAppsViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class MyAppsFragment : Fragment() {
private lateinit var mBinding: FragmentMyAppsBinding
private lateinit var mAdapter: MyAppsAdapter
private val mViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mBinding = FragmentMyAppsBinding.inflate(inflater, container, false)
mBinding.recyclerView.layoutManager = LinearLayoutManager(context)
mAdapter = MyAppsAdapter(
requireContext(),
onAppSelected = { packageName, appName ->
(requireActivity() as MainActivity).navigateToEvaluations(packageName, appName)
},
onContribute = {
(requireActivity() as MainActivity).navigateToContribute()
}
)
mBinding.recyclerView.adapter = mAdapter
mBinding.swipeRefreshLayout.setOnRefreshListener {
mViewModel.loadApps(forceRefresh = true)
}
collectState()
mViewModel.loadApps()
return mBinding.root
}
private fun collectState() {
viewLifecycleOwner.lifecycleScope.launch {
mViewModel.uiState.collect { state ->
if (state.isLoading) {
mAdapter.submitList(emptyList())
mBinding.progressBar.visibility = View.VISIBLE
} else {
mAdapter.submitList(state.items)
mBinding.progressBar.visibility = View.GONE
}
mBinding.recyclerView.visibility =
if (state.isLoading) View.GONE else View.VISIBLE
mBinding.swipeRefreshLayout.isRefreshing = false
}
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/PreferencesFragment.kt
================================================
package com.klee.sapio.ui.view
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.klee.sapio.R
class PreferencesFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.preferences, rootKey)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(view) { v, windowInsets ->
val bars = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
)
v.updatePadding(
left = bars.left,
right = bars.right,
top = bars.top,
bottom = bars.bottom
)
WindowInsetsCompat.CONSUMED
}
setupPreferenceClickListeners()
}
private fun setupPreferenceClickListeners() {
findPreference("github_star_preference")?.setOnPreferenceClickListener {
val githubUrl = getString(R.string.github_url)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubUrl))
startActivity(intent)
true
}
findPreference("about_preference")?.setOnPreferenceClickListener {
(requireActivity() as MainActivity).navigateToAbout()
true
}
findPreference("donate_preference")?.setOnPreferenceClickListener {
val donateUrl = "https://ko-fi.com/jnthnkl"
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(donateUrl))
startActivity(intent)
true
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/SearchAppAdapter.kt
================================================
package com.klee.sapio.ui.view
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.klee.sapio.databinding.SearchAppCardBinding
import com.klee.sapio.domain.model.Evaluation
class SearchAppAdapter(
private val mContext: Context,
private val onAppSelected: (packageName: String, appName: String) -> Unit
) : ListAdapter(DiffCallback) {
companion object {
private val DiffCallback = object : DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: Evaluation, newItem: Evaluation): Boolean {
return oldItem.packageName == newItem.packageName &&
oldItem.microg == newItem.microg &&
oldItem.secure == newItem.secure
}
override fun areContentsTheSame(oldItem: Evaluation, newItem: Evaluation): Boolean {
return oldItem == newItem
}
}
}
inner class ViewHolder(val binding: SearchAppCardBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = SearchAppCardBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val app = getItem(position)
val element = holder.binding
element.appName.text = app.name
element.packageName.text = app.packageName
Glide.with(mContext.applicationContext).clear(holder.binding.image)
val iconUrl = app.iconUrl
if (!iconUrl.isNullOrEmpty()) {
Glide.with(mContext.applicationContext)
.load(iconUrl)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(holder.binding.image)
}
holder.itemView.setOnClickListener {
onAppSelected(app.packageName, app.name)
}
}
override fun onViewRecycled(holder: ViewHolder) {
super.onViewRecycled(holder)
Glide.with(mContext.applicationContext).clear(holder.binding.image)
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/SearchFragment.kt
================================================
package com.klee.sapio.ui.view
import android.content.Context
import android.graphics.PorterDuff
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.Editable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.core.content.ContextCompat
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.R
import com.klee.sapio.databinding.FragmentSearchBinding
import com.klee.sapio.ui.state.SearchUiState
import com.klee.sapio.ui.viewmodel.SearchViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@AndroidEntryPoint
class SearchFragment : Fragment() {
private lateinit var mBinding: FragmentSearchBinding
private lateinit var mSearchAppAdapter: SearchAppAdapter
private val mViewModel by viewModels()
private var searchJob: Job? = null
private lateinit var mHandler: Handler
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mBinding = FragmentSearchBinding.inflate(inflater, container, false)
mBinding.recyclerView.layoutManager = LinearLayoutManager(context)
mBinding.recyclerView.visibility = View.INVISIBLE
mHandler = Handler(Looper.getMainLooper())
mSearchAppAdapter = SearchAppAdapter(requireContext()) { packageName, appName ->
(requireActivity() as MainActivity).navigateToEvaluations(packageName, appName)
}
mBinding.recyclerView.adapter = mSearchAppAdapter
mBinding.editTextSearch.addTextChangedListener { editable ->
onTextChanged(editable)
}
setupClearButton()
setSearchIconsColor()
collectSearch()
return mBinding.root
}
private fun onTextChanged(editable: Editable?) {
val text = editable?.trim().toString()
searchJob?.cancel()
searchJob = viewLifecycleOwner.lifecycleScope.launch {
mViewModel.searchApplication(text, this@SearchFragment::onNetworkError)
}
}
private fun collectSearch() {
viewLifecycleOwner.lifecycleScope.launch {
mViewModel.uiState.collect { state ->
renderState(state)
}
}
}
private fun renderState(state: SearchUiState) {
mSearchAppAdapter.submitList(state.items)
showResults(state.query.isNotEmpty() && state.items.isNotEmpty())
}
private fun setupClearButton() {
mBinding.editTextSearch.addTextChangedListener { text ->
mBinding.clearSearch.visibility = if (text?.isNotEmpty() == true) View.VISIBLE else View.GONE
}
mBinding.clearSearch.setOnClickListener {
mBinding.editTextSearch.text?.clear()
}
}
private fun setSearchIconsColor() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
mBinding.searchIcon.setColorFilter(
ContextCompat.getColor(
requireContext(),
R.color.material_dynamic_primary80
),
PorterDuff.Mode.SRC_IN
)
mBinding.searchIconBig.setColorFilter(
ContextCompat.getColor(
requireContext(),
R.color.material_dynamic_primary80
),
PorterDuff.Mode.SRC_IN
)
}
}
override fun onResume() {
super.onResume()
showKeyboard()
}
private fun onNetworkError() {
// Nothing for now
}
private fun showResults(visible: Boolean) {
if (visible) {
mBinding.recyclerView.visibility = View.VISIBLE
mBinding.emptyState.visibility = View.GONE
} else {
mBinding.recyclerView.visibility = View.INVISIBLE
mBinding.emptyState.visibility = View.VISIBLE
}
}
private fun showKeyboard() {
mBinding.editTextSearch.post {
if (!isAdded) return@post
mBinding.editTextSearch.requestFocus()
val inputMethodManager =
requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE)
as InputMethodManager
inputMethodManager.showSoftInput(
mBinding.editTextSearch,
InputMethodManager.SHOW_IMPLICIT
)
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/ShareComposable.kt
================================================
package com.klee.sapio.ui.view
import android.annotation.SuppressLint
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.klee.sapio.R
import com.klee.sapio.ui.model.Rating
import com.klee.sapio.ui.model.SharedEvaluation
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@SuppressLint("NewApi")
@Composable
fun ShareScreenshot(
sharedEvaluation: SharedEvaluation,
) {
Box(
modifier = Modifier
.requiredWidth(200.dp)
.requiredHeight(115.dp)
.background(color = Gray)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp)
) {
Box(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.align(Alignment.TopCenter),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
stringResource(R.string.android_compatibility_matrix),
style = TextStyle(
color = Color.White,
fontSize = 8.5.sp
)
)
Text(
stringResource(R.string.android_compatibility_subtitle),
style = TextStyle(
color = Color.White.copy(alpha = 0.7f),
fontSize = 5.sp
)
)
}
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
modifier = Modifier
.size(18.dp)
.align(Alignment.TopEnd),
contentDescription = "Sapio icon",
)
}
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.size(44.dp)
.background(
color = Blue200.copy(alpha = 0.18f),
shape = CircleShape
)
.align(Alignment.CenterStart),
contentAlignment = Alignment.Center
) {
Image(
bitmap = sharedEvaluation.icon.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.clip(CircleShape)
.size(36.dp),
)
}
Column(
modifier = Modifier
.align(Alignment.Center)
.padding(horizontal = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
sharedEvaluation.name,
style = TextStyle(
color = Color.White.copy(alpha = 0.9f),
fontSize = 9.sp
)
)
Text(
sharedEvaluation.packageName,
style = TextStyle(
color = Color.White.copy(alpha = 0.65f),
fontSize = 5.sp
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(6.dp))
val availableRatings = buildList {
if (hasRating(sharedEvaluation.ratingMicrog)) {
add("microG" to sharedEvaluation.ratingMicrog)
}
if (hasRating(sharedEvaluation.ratingBareAOSP)) {
add("bareAOSP" to sharedEvaluation.ratingBareAOSP)
}
}
val pillHeight = 14.dp
val pillSpacing = 3.dp
val pillStackHeight = (pillHeight * 2) + pillSpacing
val pillAreaHeight = if (availableRatings.isEmpty()) 0.dp else pillStackHeight
Box(
modifier = Modifier
.height(pillAreaHeight),
contentAlignment = Alignment.CenterStart
) {
when (availableRatings.size) {
1 -> {
val (label, rating) = availableRatings[0]
RatingPill(
label = label,
rating = rating,
modifier = Modifier.height(pillHeight)
)
}
2 -> {
Column {
availableRatings.forEachIndexed { index, (label, rating) ->
RatingPill(
label = label,
rating = rating,
modifier = Modifier.height(pillHeight)
)
if (index == 0) {
Spacer(modifier = Modifier.height(pillSpacing))
}
}
}
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.app_name),
style = TextStyle(
color = Color.White,
fontSize = 5.5.sp
)
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp)
) {
Text(
stringResource(R.string.support_privacy_focused_apps),
style = TextStyle(
color = Color.White.copy(alpha = 0.8f),
fontSize = 4.sp
),
modifier = Modifier.align(Alignment.Center)
)
Text(
LocalDate.now().format(
DateTimeFormatter.ofPattern("dd/MM/yyyy")
),
style = TextStyle(
color = Color.White.copy(alpha = 0.8f),
fontSize = 5.sp
),
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
}
}
@Composable
private fun RatingPill(
label: String,
rating: Int,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.background(Color.White.copy(alpha = 0.08f), shape = RoundedCornerShape(10.dp))
.padding(start = 7.dp, end = 7.dp, top = 3.dp, bottom = 3.dp)
) {
Text(
text = label,
style = TextStyle(
color = Color.White.copy(alpha = 0.9f),
fontSize = 6.sp
),
modifier = Modifier.width(40.dp),
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.width(4.dp))
val circleColor = when (rating) {
Rating.GOOD -> Color(0xFF4CAF50)
Rating.AVERAGE -> Color(0xFFFFC107)
Rating.BAD -> Color(0xFFF44336)
else -> Color.Transparent
}
Box(
modifier = Modifier
.size(7.dp)
.background(circleColor, shape = CircleShape)
)
}
}
private fun hasRating(rating: Int): Boolean {
return rating == Rating.GOOD || rating == Rating.AVERAGE || rating == Rating.BAD
}
const val WIDTH = 5
const val HEIGHT = 5
@Preview
@Composable
fun ShareScreenshotPreview() {
val sharedEvaluation =
SharedEvaluation(
"My great app",
"my.great.app",
Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888),
1,
2
)
ShareScreenshot(sharedEvaluation)
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/SplashActivity.kt
================================================
package com.klee.sapio.ui.view
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.klee.sapio.databinding.ActivitySplashBinding
class SplashActivity : AppCompatActivity() {
companion object {
const val SPLASH_DELAY_MS = 2000
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
window.setFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN
)
val binding = ActivitySplashBinding.inflate(layoutInflater)
setContentView(binding.root)
val delay = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) 0 else SPLASH_DELAY_MS
Handler().postDelayed({
val intent = Intent(this@SplashActivity, MainActivity::class.java)
startActivity(intent)
finish()
}, delay.toLong())
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/SuccessFragment.kt
================================================
package com.klee.sapio.ui.view
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.klee.sapio.databinding.FragmentSuccessBinding
import com.klee.sapio.ui.viewmodel.AppEvaluationsViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@AndroidEntryPoint
class SuccessFragment : Fragment() {
private lateinit var mBinding: FragmentSuccessBinding
private val mViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val packageName = arguments?.getString("package").orEmpty()
val appName = arguments?.getString("name").orEmpty()
mBinding = FragmentSuccessBinding.inflate(inflater, container, false)
mBinding.emoji.text = "\uD83C\uDF89 \uD83E\uDD73"
mViewModel.listEvaluations(packageName)
mBinding.shareEvaluation.setOnClickListener {
(requireActivity() as MainActivity).navigateToEvaluations(
packageName,
appName,
shareImmediately = true
)
}
mViewModel.uiState.onEach { state ->
mBinding.shareEvaluation.isEnabled = state.microgUser != null || state.bareAospUser != null
}.launchIn(viewLifecycleOwner.lifecycleScope)
return mBinding.root
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/ToastMessage.kt
================================================
package com.klee.sapio.ui.view
import android.content.Context
import android.widget.Toast
object ToastMessage {
fun showNetworkIssue(context: Context) {
Toast.makeText(
context,
"Sapio's server cannot be reached.",
Toast.LENGTH_LONG
).show()
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/view/WarningFragment.kt
================================================
package com.klee.sapio.ui.view
import android.os.Build
import android.os.Bundle
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import com.klee.sapio.R
import com.klee.sapio.databinding.FragmentWarningBinding
import com.klee.sapio.domain.DeviceInfo
import com.klee.sapio.domain.model.GmsType
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class WarningFragment : Fragment() {
@Inject
lateinit var mDeviceInfo: DeviceInfo
private lateinit var mBinding: FragmentWarningBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mBinding = FragmentWarningBinding.inflate(inflater, container, false)
mBinding.reportAppDescription.text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Html.fromHtml(getString(R.string.warning_desc, AboutFragment.RATING_RULES), Html.FROM_HTML_MODE_LEGACY)
} else {
@Suppress("DEPRECATION")
Html.fromHtml(getString(R.string.warning_desc, AboutFragment.RATING_RULES))
}
mBinding.reportAppDescription.movementMethod = LinkMovementMethod.getInstance()
mBinding.proceedButton.setOnClickListener {
findNavController().navigate(R.id.action_warningFragment_to_chooseAppFragment)
}
mBinding.checkbox.setOnClickListener {
updateProceedButton()
}
updateProceedButton()
return mBinding.root
}
private fun updateProceedButton() {
mBinding.proceedButton.isEnabled =
mDeviceInfo.getGmsType() != GmsType.GOOGLE_PLAY_SERVICES && mBinding.checkbox.isChecked
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/viewmodel/AppEvaluationsViewModel.kt
================================================
package com.klee.sapio.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.klee.sapio.domain.AppSettings
import com.klee.sapio.domain.FetchAppEvaluationUseCase
import com.klee.sapio.domain.FetchIconUrlUseCase
import com.klee.sapio.domain.model.GmsType
import com.klee.sapio.domain.model.UserType
import com.klee.sapio.ui.state.AppEvaluationsUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AppEvaluationsViewModel @Inject constructor(
private val fetchAppEvaluationUseCase: FetchAppEvaluationUseCase,
private val fetchIconUrlUseCase: FetchIconUrlUseCase,
private val settings: AppSettings
) : ViewModel() {
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val _uiState = MutableStateFlow(AppEvaluationsUiState())
val uiState = _uiState.asStateFlow()
private var loadingJob: Job? = null
companion object {
private const val FETCHES_WITH_UNSAFE = 5
private const val FETCHES_WITHOUT_UNSAFE = 3
}
fun listEvaluations(packageName: String) {
loadingJob?.cancel()
_uiState.value = AppEvaluationsUiState()
val expectedFetches = if (settings.isUnsafeConfigurationEnabled()) FETCHES_WITH_UNSAFE else FETCHES_WITHOUT_UNSAFE
_uiState.update { it.copy(pendingCount = expectedFetches) }
loadingJob = viewModelScope.launch {
launch(ioDispatcher) {
_uiState.update {
it.copy(
microgUser = fetchAppEvaluationUseCase(
packageName,
GmsType.MICROG,
UserType.SECURE
).getOrNull(),
pendingCount = it.pendingCount - 1
)
}
}
launch(ioDispatcher) {
_uiState.update {
it.copy(
bareAospUser = fetchAppEvaluationUseCase(
packageName,
GmsType.BARE_AOSP,
UserType.SECURE
).getOrNull(),
pendingCount = it.pendingCount - 1
)
}
}
if (settings.isUnsafeConfigurationEnabled()) {
launch(ioDispatcher) {
_uiState.update {
it.copy(
microgRoot = fetchAppEvaluationUseCase(
packageName,
GmsType.MICROG,
UserType.UNSAFE
).getOrNull(),
pendingCount = it.pendingCount - 1
)
}
}
launch(ioDispatcher) {
_uiState.update {
it.copy(
bareAospRoot = fetchAppEvaluationUseCase(
packageName,
GmsType.BARE_AOSP,
UserType.UNSAFE
).getOrNull(),
pendingCount = it.pendingCount - 1
)
}
}
}
launch(ioDispatcher) {
_uiState.update {
it.copy(iconUrl = fetchIconUrlUseCase(packageName).getOrDefault(""))
}
}
}
}
fun onIconDisplayed() {
_uiState.update { it.copy(pendingCount = it.pendingCount - 1) }
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/viewmodel/ChooseAppViewModel.kt
================================================
package com.klee.sapio.ui.viewmodel
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.klee.sapio.domain.CheckFdroidAvailabilityUseCase
import com.klee.sapio.domain.InstalledApplicationsDataSource
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.ui.state.ChooseAppUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class ChooseAppViewModel @Inject constructor(
private val installedApplicationsDataSource: InstalledApplicationsDataSource,
private val checkFdroidAvailabilityUseCase: CheckFdroidAvailabilityUseCase,
@ApplicationContext private val context: Context
) : ViewModel() {
private val _uiState = MutableStateFlow(ChooseAppUiState())
val uiState = _uiState.asStateFlow()
private val packageReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_PACKAGE_ADDED || intent.action == Intent.ACTION_PACKAGE_REMOVED) {
loadApps()
}
}
}
init {
loadApps()
val filter = IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}
ContextCompat.registerReceiver(context, packageReceiver, filter, ContextCompat.RECEIVER_EXPORTED)
}
override fun onCleared() {
super.onCleared()
context.unregisterReceiver(packageReceiver)
}
private fun loadApps() {
viewModelScope.launch {
val allApps = withContext(Dispatchers.IO) {
installedApplicationsDataSource.listInstalledApplications()
}
val filtered = filterFdroidApps(allApps)
_uiState.update { it.copy(apps = filtered, isLoading = false) }
}
}
private suspend fun filterFdroidApps(apps: List): List {
val semaphore = Semaphore(PARALLEL_REQUESTS)
return coroutineScope {
apps.map { app ->
async(Dispatchers.IO) {
semaphore.withPermit {
if (checkFdroidAvailabilityUseCase(app.packageName)) null else app
}
}
}.awaitAll().filterNotNull()
}
}
companion object {
private const val PARALLEL_REQUESTS = 10
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/viewmodel/EvaluateViewModel.kt
================================================
package com.klee.sapio.ui.viewmodel
import androidx.lifecycle.ViewModel
import com.klee.sapio.domain.DeviceInfo
import com.klee.sapio.ui.state.EvaluateUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@HiltViewModel
class EvaluateViewModel @Inject constructor(
deviceInfo: DeviceInfo
) : ViewModel() {
private val _uiState = MutableStateFlow(
EvaluateUiState(
gmsType = deviceInfo.getGmsType(),
userType = deviceInfo.isUnsafe()
)
)
val uiState = _uiState.asStateFlow()
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/viewmodel/FeedViewModel.kt
================================================
package com.klee.sapio.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.klee.sapio.domain.ListLatestEvaluationsUseCase
import com.klee.sapio.ui.state.FeedUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class FeedViewModel @Inject constructor(
private val listLatestEvaluationsUseCase: ListLatestEvaluationsUseCase
) : ViewModel() {
private var currentPage = 0
private var hasMorePages = true
private var lastLoadedUnsafeLevel: Int? = null
private val _uiState = MutableStateFlow(FeedUiState(isLoading = true))
val uiState = _uiState.asStateFlow()
init {
refresh()
}
fun syncUnsafeConfiguration(currentLevel: Int) {
val last = lastLoadedUnsafeLevel
if (last == null) {
lastLoadedUnsafeLevel = currentLevel
} else if (last != currentLevel) {
lastLoadedUnsafeLevel = currentLevel
refresh()
}
}
fun refresh() {
viewModelScope.launch {
currentPage = 0
hasMorePages = true
_uiState.update { it.copy(items = emptyList(), isLoading = true, isLoadingMore = false, hasError = false) }
loadPage()
}
}
fun loadNextPage() {
val state = _uiState.value
if (state.isLoading || state.isLoadingMore || !hasMorePages) return
viewModelScope.launch {
_uiState.update { it.copy(isLoadingMore = true) }
loadPage()
}
}
private suspend fun loadPage() {
currentPage++
val result = listLatestEvaluationsUseCase(currentPage)
if (result.isFailure) {
_uiState.update { it.copy(isLoading = false, isLoadingMore = false, hasError = true) }
return
}
val newItems = result.getOrDefault(emptyList())
_uiState.update {
val combined = (it.items + newItems).distinctBy { item ->
Triple(item.packageName, item.microg, item.secure)
}
hasMorePages = combined.size > it.items.size
it.copy(
items = combined,
isLoading = false,
isLoadingMore = false
)
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/viewmodel/LoadingViewModel.kt
================================================
package com.klee.sapio.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.klee.sapio.domain.EvaluateAppUseCase
import com.klee.sapio.domain.InstalledApplicationsDataSource
import com.klee.sapio.ui.state.EvaluateEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class LoadingViewModel @Inject constructor(
private val installedApplicationsDataSource: InstalledApplicationsDataSource,
private val evaluateAppUseCase: EvaluateAppUseCase
) : ViewModel() {
private val _events = MutableSharedFlow()
val events = _events.asSharedFlow()
fun submit(packageName: String, appName: String, rating: Int) {
viewModelScope.launch {
val app = installedApplicationsDataSource.getInstalledApplication(packageName)
if (app == null) {
_events.emit(EvaluateEvent.ShowError)
return@launch
}
val result = evaluateAppUseCase(app, rating)
if (result.isSuccess) {
_events.emit(EvaluateEvent.NavigateToSuccess(packageName, appName))
} else {
_events.emit(EvaluateEvent.ShowError)
}
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/viewmodel/MyAppsViewModel.kt
================================================
package com.klee.sapio.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.klee.sapio.domain.CheckFdroidAvailabilityUseCase
import com.klee.sapio.domain.DeviceAppCacheRepository
import com.klee.sapio.domain.DeviceInfo
import com.klee.sapio.domain.FetchAppEvaluationUseCase
import com.klee.sapio.domain.InstalledApplicationsDataSource
import com.klee.sapio.domain.model.CachedDeviceApp
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.ui.model.InstalledAppWithRating
import com.klee.sapio.ui.state.MyAppsUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
@HiltViewModel
class MyAppsViewModel @Inject constructor(
private val installedApplicationsDataSource: InstalledApplicationsDataSource,
private val fetchAppEvaluationUseCase: FetchAppEvaluationUseCase,
private val checkFdroidAvailabilityUseCase: CheckFdroidAvailabilityUseCase,
private val deviceInfo: DeviceInfo,
private val deviceAppCacheRepository: DeviceAppCacheRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(MyAppsUiState())
val uiState = _uiState.asStateFlow()
private var lastLoadTime: Long? = null
private fun isCacheValid(timestamp: Long?): Boolean {
val last = timestamp ?: return false
return System.currentTimeMillis() - last < CACHE_VALIDITY_MS
}
fun loadApps(forceRefresh: Boolean = false) {
if (_uiState.value.isLoading || _uiState.value.isRefreshing) return
if (!forceRefresh && _uiState.value.items.isNotEmpty() && isCacheValid(lastLoadTime)) return
viewModelScope.launch {
if (forceRefresh) {
_uiState.update { it.copy(isLoading = true, isRefreshing = true, progress = 0) }
fetchFromWebAndSave()
} else {
_uiState.update { it.copy(isLoading = true, progress = 0) }
val entities = withContext(Dispatchers.IO) { deviceAppCacheRepository.getAll() }
val lastCachedAt = entities.maxOfOrNull { it.cachedAt }
if (entities.isNotEmpty() && isCacheValid(lastCachedAt)) {
val result = buildListFromEntities(entities)
lastLoadTime = lastCachedAt
_uiState.update { it.copy(items = result, isLoading = false) }
} else {
fetchFromWebAndSave()
}
}
}
}
private suspend fun buildListFromEntities(
entities: List
): List {
val installedMap = withContext(Dispatchers.IO) {
installedApplicationsDataSource.listInstalledApplications().associateBy { it.packageName }
}
val total = entities.size
val result = mutableListOf()
entities.forEachIndexed { index, entity ->
val installedApp = installedMap[entity.packageName] ?: return@forEachIndexed
val evaluation = entity.rating?.let { rating ->
Evaluation(
name = installedApp.name,
packageName = entity.packageName,
iconUrl = null,
rating = rating,
microg = 0,
secure = 0,
updatedAt = null,
createdAt = null,
publishedAt = null,
versionName = null
)
}
result.add(InstalledAppWithRating(installedApp, evaluation))
_uiState.update { it.copy(progress = ((index + 1) * 100) / total) }
}
return result
}
private suspend fun fetchFromWebAndSave() {
val gmsType = deviceInfo.getGmsType()
val userType = deviceInfo.isUnsafe()
val installedApps = withContext(Dispatchers.IO) {
installedApplicationsDataSource.listInstalledApplications()
}
val total = installedApps.size
val semaphore = Semaphore(PARALLEL_REQUESTS)
val completed = AtomicInteger(0)
val result = coroutineScope {
installedApps.map { app ->
async(Dispatchers.IO) {
semaphore.withPermit {
val isFdroid = checkFdroidAvailabilityUseCase(app.packageName)
val item = if (!isFdroid) {
val evaluation = fetchAppEvaluationUseCase(
app.packageName,
gmsType,
userType
).getOrNull()
InstalledAppWithRating(app, evaluation)
} else {
null
}
_uiState.update { state ->
state.copy(progress = (completed.incrementAndGet() * 100) / total)
}
item
}
}
}.awaitAll().filterNotNull()
}
val now = System.currentTimeMillis()
val entities = result.map { item ->
CachedDeviceApp(
packageName = item.installedApp.packageName,
rating = item.evaluation?.rating,
cachedAt = now
)
}
withContext(Dispatchers.IO) {
deviceAppCacheRepository.replaceAll(entities)
}
lastLoadTime = now
_uiState.update { it.copy(items = result, isLoading = false, isRefreshing = false) }
}
companion object {
private const val CACHE_VALIDITY_MS = 86_400_000L
private const val PARALLEL_REQUESTS = 10
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/ui/viewmodel/SearchViewModel.kt
================================================
package com.klee.sapio.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.klee.sapio.domain.SearchEvaluationUseCase
import com.klee.sapio.ui.state.SearchUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SearchViewModel @Inject constructor(
private val searchEvaluationUseCase: SearchEvaluationUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(SearchUiState())
val uiState = _uiState.asStateFlow()
fun searchApplication(pattern: String, onError: () -> Unit) {
_uiState.update { it.copy(query = pattern, isLoading = true, hasError = false) }
viewModelScope.launch {
val result = searchEvaluationUseCase(pattern)
val list = result.getOrDefault(emptyList())
val hasError = result.isFailure
if (list.isEmpty() || hasError) {
onError.invoke()
}
_uiState.update {
it.copy(
items = list,
isLoading = false,
hasError = hasError
)
}
}
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/work/CompatibilityCheckScheduler.kt
================================================
package com.klee.sapio.work
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
object CompatibilityCheckScheduler {
private const val UNIQUE_WORK_NAME = "compatibility_check"
private const val UNIQUE_WORK_NOW_NAME = "compatibility_check_now"
private const val REPEAT_INTERVAL_DAYS = 7L
private const val FLEX_INTERVAL_DAYS = 1L
fun schedule(context: Context) {
if (!WorkManager.isInitialized()) {
return
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = PeriodicWorkRequestBuilder(
REPEAT_INTERVAL_DAYS,
TimeUnit.DAYS,
FLEX_INTERVAL_DAYS,
TimeUnit.DAYS
)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
request
)
}
fun runNow(context: Context) {
if (!WorkManager.isInitialized()) {
return
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = androidx.work.OneTimeWorkRequestBuilder()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
UNIQUE_WORK_NOW_NAME,
androidx.work.ExistingWorkPolicy.REPLACE,
request
)
}
}
================================================
FILE: app/src/main/java/com/klee/sapio/work/CompatibilityCheckWorker.kt
================================================
package com.klee.sapio.work
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.klee.sapio.domain.EvaluationRepository
import com.klee.sapio.domain.DeviceInfo
import com.klee.sapio.domain.InstalledApplicationsDataSource
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.domain.model.GmsType
import com.klee.sapio.domain.model.UserType
import com.klee.sapio.ui.model.Rating
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlin.random.Random
class CompatibilityCheckWorker(
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val entryPoint = EntryPointAccessors.fromApplication(
applicationContext,
CompatibilityWorkerEntryPoint::class.java
)
val deviceConfiguration = entryPoint.deviceConfiguration()
val gmsType = deviceConfiguration.getGmsType()
if (gmsType == GmsType.GOOGLE_PLAY_SERVICES) {
return Result.success()
}
val installedApps = entryPoint.installedApplicationsRepository()
.listInstalledApplications()
val evaluationRepository = entryPoint.evaluationRepository()
val userType = UserType.SECURE
val badApps = mutableListOf()
val averageApps = mutableListOf()
for (app in installedApps) {
when {
isBadCompatibility(evaluationRepository, app, gmsType, userType) -> {
badApps.add(app)
}
isAverageCompatibility(evaluationRepository, app, gmsType, userType) -> {
averageApps.add(app)
}
}
}
if (badApps.isNotEmpty()) {
val badApp = badApps[Random.nextInt(badApps.size)]
CompatibilityNotificationManager(applicationContext).show(badApp)
} else if (averageApps.isNotEmpty()) {
val averageApp = averageApps[Random.nextInt(averageApps.size)]
CompatibilityNotificationManager(applicationContext).show(averageApp)
}
return Result.success()
}
private suspend fun isBadCompatibility(
evaluationRepository: EvaluationRepository,
app: InstalledApplication,
gmsType: Int,
userType: Int
): Boolean {
val evaluation = evaluationRepository.fetchEvaluation(app.packageName, gmsType, userType).getOrNull()
return evaluation?.rating == Rating.BAD
}
private suspend fun isAverageCompatibility(
evaluationRepository: EvaluationRepository,
app: InstalledApplication,
gmsType: Int,
userType: Int
): Boolean {
val evaluation = evaluationRepository.fetchEvaluation(app.packageName, gmsType, userType).getOrNull()
return evaluation?.rating == Rating.AVERAGE
}
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface CompatibilityWorkerEntryPoint {
fun installedApplicationsRepository(): InstalledApplicationsDataSource
fun evaluationRepository(): EvaluationRepository
fun deviceConfiguration(): DeviceInfo
}
================================================
FILE: app/src/main/java/com/klee/sapio/work/CompatibilityNotificationManager.kt
================================================
package com.klee.sapio.work
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.toBitmap
import com.klee.sapio.R
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.ui.view.MainActivity
class CompatibilityNotificationManager(
private val context: Context
) {
fun show(app: InstalledApplication) {
notify(app)
}
private fun notify(app: InstalledApplication) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
ensureChannel(notificationManager)
val pendingIntent = createEvaluationPendingIntent(app, shareImmediately = false)
val sharePendingIntent = createEvaluationPendingIntent(app, shareImmediately = true)
val notification = buildNotification(app, pendingIntent, sharePendingIntent)
notificationManager.notify(NOTIFICATION_ID, notification)
}
private fun ensureChannel(notificationManager: NotificationManager) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.compatibility_check_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = context.getString(R.string.compatibility_check_channel_description)
}
notificationManager.createNotificationChannel(channel)
}
private fun createEvaluationPendingIntent(
app: InstalledApplication,
shareImmediately: Boolean
): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply {
putExtra(MainActivity.EXTRA_PACKAGE_NAME, app.packageName)
putExtra(MainActivity.EXTRA_APP_NAME, app.name)
if (shareImmediately) {
putExtra(MainActivity.EXTRA_SHARE_IMMEDIATELY, true)
putExtra(MainActivity.EXTRA_NOTIFICATION_ID, NOTIFICATION_ID)
}
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val requestCode = if (shareImmediately) {
app.packageName.hashCode() + 1
} else {
app.packageName.hashCode()
}
return PendingIntent.getActivity(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun getAppIcon(packageName: String): Bitmap? {
return try {
context.packageManager.getApplicationIcon(packageName).toBitmap()
} catch (e: PackageManager.NameNotFoundException) {
null
}
}
private fun buildNotification(
app: InstalledApplication,
pendingIntent: PendingIntent,
sharePendingIntent: PendingIntent
) = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_info)
.apply {
val icon = getAppIcon(app.packageName)
if (icon != null) {
setLargeIcon(icon)
}
}
.setContentTitle(context.getString(R.string.compatibility_check_notification_title))
.setContentText(
context.getString(
R.string.compatibility_check_notification_body,
app.name
)
)
.setStyle(
NotificationCompat.BigTextStyle().bigText(
context.getString(
R.string.compatibility_check_notification_body,
app.name
)
)
)
.setAutoCancel(false)
.setContentIntent(pendingIntent)
.addAction(
android.R.drawable.ic_menu_share,
context.getString(R.string.compatibility_check_notification_share_action),
sharePendingIntent
)
.build()
private companion object {
const val CHANNEL_ID = "compatibility_check"
const val NOTIFICATION_ID = 2201
}
}
================================================
FILE: app/src/main/res/drawable/bg_label_rounded.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_close.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_info_background.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_launcher_foreground.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_notification_info.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_phone.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_settings.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_status_green.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_status_red.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_status_yellow.xml
================================================
================================================
FILE: app/src/main/res/drawable-anydpi/ic_add.xml
================================================
================================================
FILE: app/src/main/res/drawable-anydpi/ic_search.xml
================================================
================================================
FILE: app/src/main/res/drawable-anydpi/ic_settings.xml
================================================
================================================
FILE: app/src/main/res/drawable-v33/ic_launcher_monochrome.xml
================================================
================================================
FILE: app/src/main/res/layout/activity_main.xml
================================================
================================================
FILE: app/src/main/res/layout/activity_splash.xml
================================================
================================================
FILE: app/src/main/res/layout/choose_app_card.xml
================================================
================================================
FILE: app/src/main/res/layout/dialog_choose_app.xml
================================================
================================================
FILE: app/src/main/res/layout/feed_app_card.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_about.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_choose_app.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_contribute.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_evaluate.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_evaluations.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_loading.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_main.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_my_apps.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_search.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_success.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_warning.xml
================================================
================================================
FILE: app/src/main/res/layout/my_app_card.xml
================================================
================================================
FILE: app/src/main/res/layout/search_app_card.xml
================================================
================================================
FILE: app/src/main/res/menu/bottom_menu.xml
================================================
================================================
FILE: app/src/main/res/menu/menu.xml
================================================
================================================
FILE: app/src/main/res/mipmap-anydpi-v26/ic_info.xml
================================================
================================================
FILE: app/src/main/res/mipmap-anydpi-v26/ic_info_round.xml
================================================
================================================
FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
================================================
FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
================================================
================================================
FILE: app/src/main/res/mipmap-anydpi-v26/search_icon.xml
================================================
================================================
FILE: app/src/main/res/mipmap-anydpi-v26/search_icon_round.xml
================================================
================================================
FILE: app/src/main/res/navigation/nav_graph.xml
================================================
================================================
FILE: app/src/main/res/raw/loading.json
================================================
{
"v": "5.9.0",
"fr": 60,
"ip": 0,
"op": 60,
"w": 200,
"h": 200,
"nm": "loading",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "Progress",
"sr": 1,
"ks": {
"o": {"a": 0, "k": 100},
"r": {"a": 0, "k": 0},
"p": {"a": 0, "k": [100, 100, 0]},
"a": {"a": 0, "k": [0, 0, 0]},
"s": {"a": 0, "k": [100, 100, 100]}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {"a": 0, "k": [0, 0]},
"s": {"a": 0, "k": [160, 160]},
"nm": "Ellipse"
},
{
"ty": "st",
"c": {"a": 0, "k": [0.098, 0.463, 0.824, 1]},
"o": {"a": 0, "k": 100},
"w": {"a": 0, "k": 16},
"lc": 2,
"lj": 2,
"nm": "Stroke"
},
{
"ty": "tm",
"s": {"a": 0, "k": 0},
"e": {
"a": 1,
"k": [
{"i": {"x": [1], "y": [1]}, "o": {"x": [0], "y": [0]}, "t": 0, "s": [0]},
{"t": 60, "s": [100]}
]
},
"o": {"a": 0, "k": -90},
"m": 1,
"nm": "Trim"
},
{
"ty": "tr",
"p": {"a": 0, "k": [0, 0]},
"a": {"a": 0, "k": [0, 0]},
"s": {"a": 0, "k": [100, 100]},
"r": {"a": 0, "k": 0},
"o": {"a": 0, "k": 100}
}
],
"nm": "Group"
}
],
"ip": 0,
"op": 60,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 2,
"ty": 4,
"nm": "Track",
"sr": 1,
"ks": {
"o": {"a": 0, "k": 20},
"r": {"a": 0, "k": 0},
"p": {"a": 0, "k": [100, 100, 0]},
"a": {"a": 0, "k": [0, 0, 0]},
"s": {"a": 0, "k": [100, 100, 100]}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {"a": 0, "k": [0, 0]},
"s": {"a": 0, "k": [160, 160]},
"nm": "Ellipse"
},
{
"ty": "st",
"c": {"a": 0, "k": [0.098, 0.463, 0.824, 1]},
"o": {"a": 0, "k": 100},
"w": {"a": 0, "k": 16},
"lc": 2,
"lj": 2,
"nm": "Stroke"
},
{
"ty": "tr",
"p": {"a": 0, "k": [0, 0]},
"a": {"a": 0, "k": [0, 0]},
"s": {"a": 0, "k": [100, 100]},
"r": {"a": 0, "k": 0},
"o": {"a": 0, "k": 100}
}
],
"nm": "Group"
}
],
"ip": 0,
"op": 60,
"st": 0,
"bm": 0
}
]
}
================================================
FILE: app/src/main/res/values/colors.xml
================================================
#FFBB86FC#FF3700B3#90CAF9#1976D2#FF000000#FFFFFFFF#212121#E0E0E0@color/grey@color/white@color/white
================================================
FILE: app/src/main/res/values/dimens.xml
================================================
16dp16dp
================================================
FILE: app/src/main/res/values/strings.xml
================================================
SapioNextPrevious Therefore, your device must not have the official Google Play Services installed
Make sure to understand properly the rating rules before contributing.]]>ProceedSelectSelect the application to evaluateThe app does not have dependencies to Google Play ServicesYou can now evaluate the applicationPlease select an evaluationmicroGbareAOSPsecureunsafe\ It works perfectly. All features are working.\ It works partially. Some features do not work.\ It does not work at all or crashes.Error evaluating appSapio is the anagram of Open Source API.\n\nSapio provides the compatibility of an Android application running on a device without Google Play Services (i.e. deGoogled bare Android Open Source Project (AOSP) devices, coupled or not with microG).\n\nSapio can serve as a lobbying tool by sharing compatibility on social media to raise awareness among app developers about respecting users\' personal data.\n\nEvaluations in Sapio are given to the community by the community.Brain icons created by Freepik - FlaticonEnvironment detectedLook for an applicationSearch applications...Updated on %1$sAndroid Compatibilitywithout Google Play ServicesApplication namePackage nameFeedSearchContributeOptionsDeviceAboutValidateThank you for evaluating the applicationWorks perfectlyOne or more feature don\'t workDoes not work at allUnknownShareFeel free to share your evaluation on social media to raise awareness among app developers about respecting users\' personal data.%1$s Android Compatibility%1$s Android Compatibility https://github.com/jonathanklee/Sapio #degoogle #sapioSupport privacy-focused Android apps!Buy me a coffeeSettingsCompatibility checksNotifications about apps that are incompatible with your configuration.Incompatible app%1$s doesn\'t seem to work properly without Google Play Services. Help us make noise -- share this and tag the developer to request a privacy-respecting version!Share nowRating rules]]>I have read and I understand the rating rulesEnvironmentsShow unsafe environmentsGeneralStar on GitHubhttps://github.com/jonathanklee/Sapio
================================================
FILE: app/src/main/res/values/themes.xml
================================================
================================================
FILE: app/src/main/res/values-de/strings.xml
================================================
WeiterZurück Daher darf auf Ihrem Gerät das offizielle Google Play Services nicht installiert sein.
Stellen Sie sicher, die Bewertungsregeln richtig zu verstehen, bevor Sie beitragen.]]>WeiterAuswählenWählen Sie die zu bewertende Anwendung ausDie App hat keine Abhängigkeiten zu Google Play ServicesSie können die Anwendung jetzt bewertenBitte wählen Sie eine Bewertung aus\ Funktioniert perfekt. Alle Funktionen sind nutzbar.\ Funktioniert teilweise. Einige Funktionen funktionieren nicht.\ Funktioniert überhaupt nicht oder stürzt ab.Fehler bei der App-BewertungSapio ist das Anagramm von Open Source API.\n\nSapio bietet die Kompatibilität einer Android-Anwendung auf einem Gerät ohne Google Play Services (d.h. de-gegoogelte Android Open Source Project (AOSP)-Geräte, mit oder ohne microG).\n\nSapio kann als Lobbying-Tool dienen, indem Kompatibilitätsinformationen in sozialen Medien geteilt werden, um App-Entwickler für den Schutz persönlicher Daten zu sensibilisieren.\n\nBewertungen in Sapio werden von der Community für die Community gegeben.Gehirn-Icons erstellt von Freepik - FlaticonUmgebung erkanntEine Anwendung suchenAnwendungen suchen…Aktualisiert am %1$sAndroid-Kompatibilitätohne Google Play ServicesAnwendungsnamePaketnameFeedSuchenBeitragenGerätOptionenÜberBestätigenVielen Dank für die Bewertung der AnwendungFunktioniert perfektEine oder mehrere Funktionen funktionieren nichtFunktioniert überhaupt nichtUnbekanntTeilenTeilen Sie gerne Ihre Bewertung in sozialen Medien, um App-Entwickler für den Schutz persönlicher Daten zu sensibilisieren.Android-Kompatibilität von %1$sAndroid-Kompatibilität von %1$s https://github.com/jonathanklee/Sapio #degoogle #sapioUnterstützen Sie datenschutzorientierte Android-Apps!Einen Kaffee ausgebenEinstellungenKompatibilitätsprüfungenBenachrichtigungen über Apps, die mit Ihrer Konfiguration nicht kompatibel sind.Inkompatible App%1$s scheint ohne Google Play Services nicht richtig zu funktionieren. Helfen Sie uns, auf das Problem aufmerksam zu machen — teilen Sie dies und markieren Sie den Entwickler, um eine datenschutzfreundliche Version anzufordern!Jetzt teilenBewertungsregeln]]>Ich habe die Bewertungsregeln gelesen und verstehe sieUmgebungenRiskante Umgebungen anzeigenAllgemeinAuf GitHub bewertenhttps://github.com/jonathanklee/Sapio
================================================
FILE: app/src/main/res/values-es/strings.xml
================================================
SiguienteAnterior Por tanto, tu dispositivo no debe tener instalados los Google Play Services oficiales.
Asegúrate de entender correctamente las reglas de evaluación antes de contribuir.]]>ContinuarSeleccionarSelecciona la aplicación a evaluarLa app no tiene dependencias de Google Play ServicesAhora puedes evaluar la aplicaciónPor favor, selecciona una evaluación\ Funciona perfectamente. Todas las funciones están operativas.\ Funciona parcialmente. Algunas funciones no funcionan.\ No funciona en absoluto o se bloquea.Error al evaluar la appSapio es el anagrama de Open Source API.\n\nSapio proporciona la compatibilidad de una aplicación Android en un dispositivo sin Google Play Services (es decir, dispositivos Android Open Source Project (AOSP) desgoogleados, con o sin microG).\n\nSapio puede servir como herramienta de presión compartiendo la compatibilidad en redes sociales para concienciar a los desarrolladores de apps sobre el respeto de los datos personales de los usuarios.\n\nLas evaluaciones en Sapio son dadas a la comunidad por la comunidad.Iconos de cerebro creados por Freepik - FlaticonEntorno detectadoBuscar una aplicaciónBuscar aplicaciones…Actualizado el %1$sCompatibilidad Androidsin Google Play ServicesNombre de la aplicaciónNombre del paqueteNovedadesBuscarContribuirDispositivoOpcionesAcerca deValidarGracias por evaluar la aplicaciónFunciona perfectamenteUna o más funciones no funcionanNo funciona en absolutoDesconocidoCompartirNo dudes en compartir tu evaluación en las redes sociales para concienciar a los desarrolladores sobre el respeto de los datos personales de los usuarios.Compatibilidad Android de %1$sCompatibilidad Android de %1$s https://github.com/jonathanklee/Sapio #degoogle #sapio¡Apoya las apps Android centradas en la privacidad!Invitar a un caféConfiguraciónComprobaciones de compatibilidadNotificaciones sobre apps incompatibles con tu configuración.App incompatible%1$s no parece funcionar correctamente sin Google Play Services. ¡Ayúdanos a hacer ruido — comparte esto y etiqueta al desarrollador para solicitar una versión respetuosa con la privacidad!Compartir ahoraReglas de evaluación]]>He leído y entiendo las reglas de evaluaciónEntornosMostrar entornos arriesgadosGeneralDar estrella en GitHubhttps://github.com/jonathanklee/Sapio
================================================
FILE: app/src/main/res/values-fr/strings.xml
================================================
SuivantPrécédent Par conséquent, votre appareil ne doit pas avoir les Google Play Services d\'installés.
Soyez certains de bien comprendre les règles d\'évaluation avant de contribuer.]]>CommencerSélectionnerSelectionnez l\'application à évaluerL\'app n\'a pas de dépendances vers les Google Play ServicesVous pouvez désormais évaluer l\'applicationVeuillez sélectionner une évaluation\ Elle fonctionne parfaitement.\ Elle fonctionne partiellement.\ Elle ne fonctionne pas du tout ou crashe.Erreur lors de l\'évaluationSapio est l\'anagramme d\'Open Source API.\n\nSapio fournit la compatibilité d\'une application Android sur un appareil sans Google Play Services (i.e. les téléphones fonctionnant sur un Android Open Source Project (AOSP) déGooglisé, associés ou non à microG).\n\nSapio peut être utilisé comme un outil de lobbying en partageant la compatibilité sur les réseaux sociaux pour sensibiliser les développeurs d\'applications aux données personnelles des utilisateurs.\n\nLes évaluations de Sapio sont fournies à la communauté par la communauté.Icone cerveau créé par Freepik - FlaticonEnvironnement détéctéRechercher une applicationRechercher des applications ...Mis à jour le %1$sCompatibilité Androidsans Google Play ServicesNom de l\'applicationNom du paquetActualitéRechercherContribuerAppareilOptionsA proposOffrir un caféValiderMerci d\'avoir évalué l\'applicationFonctionne parfaitementFonctionne partiellementNe fonctionne pas du toutInconnuParamètresPartagerRègles d\'évaluation]]>J\'ai lu et je comprends les règles d\'évaluationN\'hésitez pas à partager votre évaluation sur les réseaux sociaux pour sensibiliser les développeurs au respect des données personnelles.%1$s Compatibilité Android%1$s Compatibilité Android https://github.com/jonathanklee/Sapio #degoogle #sapioVérifications de compatibilitéNotifications sur les applications incompatibles avec votre configuration.App incompatibleL\'application %1$s ne semble pas fonctionner correctement sans Google Play Services. Aidez-nous à faire du bruit - partagez ceci et taguez le développeur pour demander une version respectueuse de la vie privée !Partager maintenantSoutenez les applications Android respectueuses de la vie privée !EnvironnementsGénéralMontrer les environnements non-securisésÉtoiler sur GitHubhttps://github.com/jonathanklee/Sapio
================================================
FILE: app/src/main/res/values-it/strings.xml
================================================
AvantiIndietro Pertanto, il tuo dispositivo non deve avere i Google Play Services ufficiali installati.
Assicurati di capire correttamente le regole di valutazione prima di contribuire.]]>ProcediSelezionaSeleziona l\'applicazione da valutareL\'app non ha dipendenze da Google Play ServicesOra puoi valutare l\'applicazioneSeleziona una valutazione\ Funziona perfettamente. Tutte le funzionalità sono operative.\ Funziona parzialmente. Alcune funzionalità non funzionano.\ Non funziona affatto o va in crash.Errore nella valutazione dell\'appSapio è l\'anagramma di Open Source API.\n\nSapio fornisce la compatibilità di un\'applicazione Android su un dispositivo senza Google Play Services (es. dispositivi Android Open Source Project (AOSP) de-googlizzati, con o senza microG).\n\nSapio può essere usato come strumento di lobbying condividendo la compatibilità sui social media per sensibilizzare gli sviluppatori di app sul rispetto dei dati personali degli utenti.\n\nLe valutazioni in Sapio sono date alla comunità dalla comunità.Icone cervello create da Freepik - FlaticonAmbiente rilevatoCerca un\'applicazioneCerca applicazioni…Aggiornato il %1$sCompatibilità Androidsenza Google Play ServicesNome dell\'applicazioneNome del pacchettoFeedCercaContribuisciDispositivoOpzioniInformazioniValidaGrazie per aver valutato l\'applicazioneFunziona perfettamenteUna o più funzionalità non funzionanoNon funziona affattoSconosciutoCondividiCondividi la tua valutazione sui social media per sensibilizzare gli sviluppatori di app sul rispetto dei dati personali degli utenti.Compatibilità Android di %1$sCompatibilità Android di %1$s https://github.com/jonathanklee/Sapio #degoogle #sapioSupporta le app Android rispettose della privacy!Offrimi un caffèImpostazioniControlli di compatibilitàNotifiche sulle app incompatibili con la tua configurazione.App incompatibile%1$s non sembra funzionare correttamente senza Google Play Services. Aiutaci a far sentire la nostra voce — condividi e tagga lo sviluppatore per richiedere una versione rispettosa della privacy!Condividi oraRegole di valutazione]]>Ho letto e capisco le regole di valutazioneAmbientiMostra ambienti rischiosiGeneraleMetti una stella su GitHubhttps://github.com/jonathanklee/Sapio
================================================
FILE: app/src/main/res/values-land/dimens.xml
================================================
48dp
================================================
FILE: app/src/main/res/values-night/colors.xml
================================================
@color/grey
================================================
FILE: app/src/main/res/values-night-v31/themes.xml
================================================
================================================
FILE: app/src/main/res/values-v31/themes.xml
================================================
================================================
FILE: app/src/main/res/values-w1240dp/dimens.xml
================================================
200dp
================================================
FILE: app/src/main/res/values-w600dp/dimens.xml
================================================
48dp
================================================
FILE: app/src/main/res/xml/preferences.xml
================================================
================================================
FILE: app/src/test/java/com/klee/sapio/AppEvaluationsViewModelTest.kt
================================================
package com.klee.sapio
import android.os.Build
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.klee.sapio.data.system.Settings
import com.klee.sapio.domain.model.GmsType
import com.klee.sapio.domain.model.UserType
import com.klee.sapio.domain.EvaluationRepository
import com.klee.sapio.domain.FetchAppEvaluationUseCase
import com.klee.sapio.domain.FetchIconUrlUseCase
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.domain.model.EvaluationRecord
import com.klee.sapio.domain.model.Icon
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.domain.model.UploadEvaluation
import com.klee.sapio.ui.viewmodel.AppEvaluationsViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, sdk = [Build.VERSION_CODES.M])
class AppEvaluationsViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private val dispatcher = StandardTestDispatcher()
private val appContext = RuntimeEnvironment.getApplication()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `listEvaluations posts only user evaluations when unsafe disabled`() = runTest(dispatcher) {
val vm = buildViewModel(unsafeEnabled = false)
vm.listEvaluations("pkg")
advanceUntilIdle()
vm.onIconDisplayed()
val state = vm.uiState.value
assertEquals("microg-secure", state.microgUser?.name)
assertEquals("bare-secure", state.bareAospUser?.name)
assertNull(state.microgRoot)
assertNull(state.bareAospRoot)
assertEquals("https://icon", state.iconUrl)
assertTrue(state.isFullyLoaded)
assertEquals(0, state.pendingCount)
}
@Test
fun `listEvaluations posts unsafe evaluations when unsafe enabled`() = runTest(dispatcher) {
val vm = buildViewModel(unsafeEnabled = true)
vm.listEvaluations("pkg")
advanceUntilIdle()
vm.onIconDisplayed()
val state = vm.uiState.value
assertEquals("microg-unsafe", state.microgRoot?.name)
assertEquals("bare-unsafe", state.bareAospRoot?.name)
assertTrue(state.isFullyLoaded)
assertEquals(0, state.pendingCount)
}
@Test
fun `listEvaluations handles null evaluations`() = runTest(dispatcher) {
val vm = buildViewModel(unsafeEnabled = true, returnNullEvals = true, iconUrl = "")
vm.listEvaluations("pkg")
advanceUntilIdle()
vm.onIconDisplayed()
val state = vm.uiState.value
assertNull(state.microgUser)
assertNull(state.bareAospUser)
assertNull(state.microgRoot)
assertNull(state.bareAospRoot)
assertEquals("", state.iconUrl)
assertTrue(state.isFullyLoaded)
assertEquals(0, state.pendingCount)
}
private fun buildViewModel(
unsafeEnabled: Boolean,
returnNullEvals: Boolean = false,
iconUrl: String = "https://icon"
): AppEvaluationsViewModel {
val mockRepository = object : EvaluationRepository {
override suspend fun listLatestEvaluations(pageNumber: Int): Result> =
Result.success(emptyList())
override suspend fun searchEvaluations(pattern: String): Result> =
Result.success(emptyList())
override suspend fun addEvaluation(evaluation: UploadEvaluation): Result = Result.success(Unit)
override suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int): Result =
Result.success(Unit)
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int): Result =
Result.success(null)
override suspend fun existingEvaluations(packageName: String): Result> =
Result.success(emptyList())
override suspend fun uploadIcon(packageName: String): Result> =
Result.success(emptyList())
override suspend fun existingIcon(iconName: String): Result> =
Result.success(emptyList())
override suspend fun deleteIcon(id: Int): Result = Result.success(Unit)
}
val fetchEvaluationUseCase = object : FetchAppEvaluationUseCase(mockRepository) {
override suspend fun invoke(packageName: String, gmsType: Int, userType: Int): Result {
if (returnNullEvals) return Result.success(null)
val name = when {
gmsType == GmsType.MICROG && userType == UserType.SECURE -> "microg-secure"
gmsType == GmsType.MICROG && userType == UserType.UNSAFE -> "microg-unsafe"
gmsType == GmsType.BARE_AOSP && userType == UserType.SECURE -> "bare-secure"
else -> "bare-unsafe"
}
return Result.success(eval(name, packageName))
}
}
val iconUrlUseCase = object : FetchIconUrlUseCase(mockRepository) {
override suspend fun invoke(packageName: String): Result = Result.success(iconUrl)
}
val settingsObj = object : Settings(appContext) {
override fun isUnsafeConfigurationEnabled(): Boolean = unsafeEnabled
}
return AppEvaluationsViewModel(
fetchEvaluationUseCase,
iconUrlUseCase,
settingsObj
).apply {
ioDispatcher = dispatcher
}
}
private fun eval(name: String, packageName: String) = Evaluation(
name = name,
packageName = packageName,
iconUrl = null,
rating = 1,
microg = 1,
secure = 1,
updatedAt = null,
createdAt = null,
publishedAt = null,
versionName = null
)
}
================================================
FILE: app/src/test/java/com/klee/sapio/DeviceConfigurationTest.kt
================================================
package com.klee.sapio
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.os.Build
import com.klee.sapio.data.system.DeviceConfiguration
import com.klee.sapio.domain.model.GmsType
import com.klee.sapio.domain.model.UserType
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
import org.robolectric.util.ReflectionHelpers
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, sdk = [Build.VERSION_CODES.M])
class DeviceConfigurationTest {
private lateinit var deviceConfiguration: DeviceConfiguration
@Mock
private lateinit var mockedContext: Context
@Mock
private lateinit var mockedPackageManager: PackageManager
private lateinit var fakeGmsApp: ApplicationInfo
private lateinit var fakeMicroGApp: ApplicationInfo
private lateinit var fakeRegularApp: ApplicationInfo
@Before
fun setUp() {
MockitoAnnotations.openMocks(this)
fakeGmsApp = ApplicationInfo().apply {
packageName = DeviceConfiguration.GMS_SERVICES_PACKAGE_NAME
name = "Google Play Services"
}
fakeMicroGApp = ApplicationInfo().apply {
packageName = DeviceConfiguration.GMS_SERVICES_PACKAGE_NAME
name = "microG Services Core"
}
fakeRegularApp = ApplicationInfo().apply {
packageName = "com.example.app"
name = "Example App"
}
// Setup the mocked context and package manager BEFORE creating DeviceConfiguration
Mockito.`when`(mockedContext.packageManager).thenReturn(mockedPackageManager)
// Create DeviceConfiguration with mocked context
deviceConfiguration = DeviceConfiguration(mockedContext)
}
@Test
fun test_getGmsType_withGooglePlayServices() {
// Setup the mock behavior BEFORE creating DeviceConfiguration
val apps = listOf(fakeGmsApp, fakeRegularApp)
Mockito.`when`(mockedPackageManager.getInstalledApplications(0))
.thenReturn(apps)
Mockito.`when`(mockedPackageManager.getApplicationLabel(eq(fakeGmsApp)))
.thenReturn("Google Play Services")
// Recreate DeviceConfiguration with the properly mocked context
deviceConfiguration = DeviceConfiguration(mockedContext)
val result = deviceConfiguration.getGmsType()
Assert.assertEquals("Should return Google Play Services type", GmsType.GOOGLE_PLAY_SERVICES, result)
}
@Test
fun test_getGmsType_withMicroG() {
// Setup the mock behavior BEFORE creating DeviceConfiguration
val apps = listOf(fakeMicroGApp, fakeRegularApp)
Mockito.`when`(mockedPackageManager.getInstalledApplications(0))
.thenReturn(apps)
Mockito.`when`(mockedPackageManager.getApplicationLabel(eq(fakeMicroGApp)))
.thenReturn("microG Services Core")
// Recreate DeviceConfiguration with the properly mocked context
deviceConfiguration = DeviceConfiguration(mockedContext)
val result = deviceConfiguration.getGmsType()
Assert.assertEquals("Should return microG type", GmsType.MICROG, result)
}
@Test
fun test_getGmsType_withMicroGRenamedAsAppCompatibilityServices() {
val renamedMicroGApp = ApplicationInfo().apply {
packageName = DeviceConfiguration.GMS_SERVICES_PACKAGE_NAME
}
val apps = listOf(renamedMicroGApp, fakeRegularApp)
Mockito.`when`(mockedPackageManager.getInstalledApplications(0))
.thenReturn(apps)
Mockito.`when`(mockedPackageManager.getApplicationLabel(eq(renamedMicroGApp)))
.thenReturn("App compatibility Services")
deviceConfiguration = DeviceConfiguration(mockedContext)
val result = deviceConfiguration.getGmsType()
Assert.assertEquals(GmsType.MICROG, result)
}
@Test
fun test_getGmsType_withBareAosp() {
// Setup the mock behavior BEFORE creating DeviceConfiguration
val apps = listOf(fakeRegularApp)
Mockito.`when`(mockedPackageManager.getInstalledApplications(0))
.thenReturn(apps)
// Recreate DeviceConfiguration with the properly mocked context
deviceConfiguration = DeviceConfiguration(mockedContext)
val result = deviceConfiguration.getGmsType()
Assert.assertEquals("Should return bare AOSP type", GmsType.BARE_AOSP, result)
}
@Test
fun test_isUnsafe_withRootedAndUnlockedBootloader() {
// This test is more complex due to the private isBootloaderLocked method
// We'll test the public behavior by mocking the RootBeer result
val apps = listOf(fakeRegularApp)
Mockito.`when`(mockedPackageManager.getInstalledApplications(0))
.thenReturn(apps)
// For this test, we'll assume the device is rooted and bootloader is unlocked
// Since we can't easily mock the private method, we'll test the happy path
val result = deviceConfiguration.isUnsafe()
// The actual result depends on the device state, so we'll just verify it returns a valid value
Assert.assertTrue("Should return either UNSAFE or SECURE",
result == UserType.UNSAFE || result == UserType.SECURE)
}
@Test
fun test_isBootloaderLocked_states() {
// Force bootloader state via ShadowSystemProperties used by SystemPropertyReader inside DeviceConfiguration
ReflectionHelpers.callStaticMethod(
Class.forName("android.os.SystemProperties"),
"set",
org.robolectric.util.ReflectionHelpers.ClassParameter.from(String::class.java, "ro.boot.verifiedbootstate"),
org.robolectric.util.ReflectionHelpers.ClassParameter.from(String::class.java, "green")
)
Assert.assertEquals(UserType.SECURE, deviceConfiguration.isUnsafe()) // green => locked -> secure if not rooted
ReflectionHelpers.callStaticMethod(
Class.forName("android.os.SystemProperties"),
"set",
org.robolectric.util.ReflectionHelpers.ClassParameter.from(String::class.java, "ro.boot.verifiedbootstate"),
org.robolectric.util.ReflectionHelpers.ClassParameter.from(String::class.java, "red")
)
val redResult = deviceConfiguration.isUnsafe()
Assert.assertTrue(redResult == UserType.UNSAFE || redResult == UserType.SECURE)
}
@Test
fun test_isUnsafe_branch_with_overrides() {
val fake = object : DeviceConfiguration(mockedContext) {
override fun isRooted(): Boolean = true
override fun isBootloaderLocked(): Boolean = false
}
Assert.assertEquals(UserType.UNSAFE, fake.isUnsafe())
val secure = object : DeviceConfiguration(mockedContext) {
override fun isRooted(): Boolean = true
override fun isBootloaderLocked(): Boolean = true
}
Assert.assertEquals(UserType.SECURE, secure.isUnsafe())
val clean = object : DeviceConfiguration(mockedContext) {
override fun isRooted(): Boolean = false
override fun isBootloaderLocked(): Boolean = false
}
Assert.assertEquals(UserType.SECURE, clean.isUnsafe())
}
}
================================================
FILE: app/src/test/java/com/klee/sapio/DomainUseCasesTest.kt
================================================
package com.klee.sapio
import com.klee.sapio.domain.FetchAppEvaluationUseCase
import com.klee.sapio.domain.FetchIconUrlUseCase
import com.klee.sapio.domain.EvaluationRepository
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.domain.model.GmsType
import com.klee.sapio.domain.model.Icon
import com.klee.sapio.domain.model.UserType
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations
@OptIn(ExperimentalCoroutinesApi::class)
class DomainUseCasesTest {
@Mock
lateinit var evaluationRepository: EvaluationRepository
private lateinit var fetchIconUrlUseCase: FetchIconUrlUseCase
private lateinit var fetchEvaluationUseCase: FetchAppEvaluationUseCase
@Before
fun setUp() {
MockitoAnnotations.openMocks(this)
fetchIconUrlUseCase = FetchIconUrlUseCase(evaluationRepository)
fetchEvaluationUseCase = FetchAppEvaluationUseCase(evaluationRepository)
}
@Test
fun `fetch icon url returns first url when icons exist`() = runTest {
val icon = Icon(
id = 1,
name = "icon.png",
url = "https://example.com/icon.png"
)
`when`(evaluationRepository.existingIcon("com.test.png")).thenReturn(Result.success(listOf(icon)))
val result = fetchIconUrlUseCase("com.test")
assertEquals("https://example.com/icon.png", result.getOrNull())
verify(evaluationRepository).existingIcon("com.test.png")
}
@Test
fun `fetch icon url returns empty string when no icons`() = runTest {
`when`(evaluationRepository.existingIcon("com.empty.png")).thenReturn(Result.success(emptyList()))
val result = fetchIconUrlUseCase("com.empty")
assertEquals("", result.getOrNull())
verify(evaluationRepository).existingIcon("com.empty.png")
}
@Test
fun `fetch microg secure delegates to repository`() = runTest {
val expected = dummyEvaluation("microg.secure")
`when`(evaluationRepository.fetchEvaluation("pkg", GmsType.MICROG, UserType.SECURE))
.thenReturn(Result.success(expected))
val result = fetchEvaluationUseCase("pkg", GmsType.MICROG, UserType.SECURE)
assertEquals(expected, result.getOrNull())
verify(evaluationRepository).fetchEvaluation("pkg", GmsType.MICROG, UserType.SECURE)
}
@Test
fun `fetch microg unsafe delegates to repository`() = runTest {
val expected = dummyEvaluation("microg.unsafe")
`when`(evaluationRepository.fetchEvaluation("pkg", GmsType.MICROG, UserType.UNSAFE))
.thenReturn(Result.success(expected))
val result = fetchEvaluationUseCase("pkg", GmsType.MICROG, UserType.UNSAFE)
assertEquals(expected, result.getOrNull())
verify(evaluationRepository).fetchEvaluation("pkg", GmsType.MICROG, UserType.UNSAFE)
}
@Test
fun `fetch bare aosp secure delegates to repository`() = runTest {
val expected = dummyEvaluation("bare.secure")
`when`(evaluationRepository.fetchEvaluation("pkg", GmsType.BARE_AOSP, UserType.SECURE))
.thenReturn(Result.success(expected))
val result = fetchEvaluationUseCase("pkg", GmsType.BARE_AOSP, UserType.SECURE)
assertEquals(expected, result.getOrNull())
verify(evaluationRepository).fetchEvaluation("pkg", GmsType.BARE_AOSP, UserType.SECURE)
}
@Test
fun `fetch bare aosp unsafe delegates to repository`() = runTest {
val expected = dummyEvaluation("bare.unsafe")
`when`(evaluationRepository.fetchEvaluation("pkg", GmsType.BARE_AOSP, UserType.UNSAFE))
.thenReturn(Result.success(expected))
val result = fetchEvaluationUseCase("pkg", GmsType.BARE_AOSP, UserType.UNSAFE)
assertEquals(expected, result.getOrNull())
verify(evaluationRepository).fetchEvaluation("pkg", GmsType.BARE_AOSP, UserType.UNSAFE)
}
private fun dummyEvaluation(name: String) = Evaluation(
name = name,
packageName = "pkg",
iconUrl = null,
rating = 1,
microg = 1,
secure = 1,
updatedAt = null,
createdAt = null,
publishedAt = null,
versionName = null
)
}
================================================
FILE: app/src/test/java/com/klee/sapio/EvaluateAppUseCaseBehaviourTest.kt
================================================
package com.klee.sapio
import android.os.Build
import com.klee.sapio.domain.EvaluateAppUseCase
import com.klee.sapio.data.system.DeviceConfiguration
import com.klee.sapio.domain.model.Icon
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.domain.model.UploadEvaluation
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, sdk = [Build.VERSION_CODES.M])
class EvaluateAppUseCaseBehaviourTest {
private lateinit var useCase: EvaluateAppUseCase
private lateinit var deviceConfiguration: DeviceConfiguration
private lateinit var repository: FakeRepository
private val installedApp = InstalledApplication(
name = "Demo",
packageName = "com.demo.app"
)
@Before
fun setup() {
repository = FakeRepository()
val roboContext = org.robolectric.RuntimeEnvironment.getApplication()
deviceConfiguration = DeviceConfiguration(roboContext)
useCase = EvaluateAppUseCase(repository, deviceConfiguration)
}
@Test
fun `invoke uploads, deletes old icons and adds evaluation`() = runTest {
repository.existingIcons = listOf(
iconAnswer(id = 10, url = "old1"),
iconAnswer(id = 11, url = "old2")
)
repository.uploadResponse = arrayListOf(iconAnswer(id = 99, url = "new"))
val result = useCase(installedApp, rating = 5)
assertTrue(repository.deletedIds.containsAll(listOf(10, 11)))
assertTrue(repository.addedEvaluations.isNotEmpty())
assertEquals(99, repository.addedEvaluations.first().icon)
assertEquals(5, repository.addedEvaluations.first().rating)
assertTrue(result.isSuccess)
}
@Test
fun `invoke returns failure when upload fails`() = runTest {
repository.existingIcons = emptyList()
repository.uploadResponse = null
val result = useCase(installedApp, rating = 2)
assertTrue(result.isFailure)
assertTrue(repository.addedEvaluations.isEmpty())
assertTrue(repository.deletedIds.isEmpty())
}
private fun iconAnswer(id: Int, url: String) = Icon(id = id, name = "icon$id", url = url)
private class FakeRepository : com.klee.sapio.domain.EvaluationRepository {
var existingIcons: List = emptyList()
var uploadResponse: List? = null
val deletedIds = mutableListOf()
val addedEvaluations = mutableListOf()
override suspend fun listLatestEvaluations(pageNumber: Int): Result> =
Result.success(emptyList())
override suspend fun searchEvaluations(pattern: String): Result> =
Result.success(emptyList())
override suspend fun addEvaluation(evaluation: UploadEvaluation): Result {
addedEvaluations.add(evaluation)
return Result.success(Unit)
}
override suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int): Result = Result.success(Unit)
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int): Result =
Result.success(null)
override suspend fun existingEvaluations(packageName: String): Result> =
Result.success(emptyList())
override suspend fun uploadIcon(packageName: String): Result> =
Result.success(uploadResponse ?: emptyList())
override suspend fun existingIcon(iconName: String): Result> =
Result.success(existingIcons)
override suspend fun deleteIcon(id: Int): Result {
deletedIds.add(id)
return Result.success(Unit)
}
}
}
================================================
FILE: app/src/test/java/com/klee/sapio/EvaluateAppUseCaseTest.kt
================================================
package com.klee.sapio
import android.os.Build
import com.klee.sapio.domain.EvaluateAppUseCase
import com.klee.sapio.domain.model.Icon
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.domain.model.UploadEvaluation
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.domain.model.EvaluationRecord
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
@ExperimentalCoroutinesApi
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, sdk = [Build.VERSION_CODES.M])
class EvaluateAppUseCaseTest {
private lateinit var evaluateAppUseCase: EvaluateAppUseCase
private lateinit var fakeRepository: FakeRepository
private lateinit var realInstalledApplication: InstalledApplication
private val testDispatcher = UnconfinedTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
val roboContext = org.robolectric.RuntimeEnvironment.getApplication()
val deviceConfiguration = com.klee.sapio.data.system.DeviceConfiguration(roboContext)
fakeRepository = FakeRepository()
evaluateAppUseCase = EvaluateAppUseCase(fakeRepository, deviceConfiguration)
realInstalledApplication = InstalledApplication(
name = "Test App",
packageName = "com.test.app"
)
fakeRepository.addEvaluationResult = Result.success(Unit)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun test_evaluateApp_withFailedIconUpload() = runTest {
fakeRepository.uploadIconResult = Result.success(emptyList())
fakeRepository.existingIconsResult = Result.success(emptyList())
val result = evaluateAppUseCase(realInstalledApplication, 1)
Assert.assertTrue("Should return failure", result.isFailure)
}
@Test
fun test_evaluateApp_withSuccessfulIconUpload() = runTest {
val fakeIcon = Icon(id = 123, name = "test.png", url = "http://example.com/test.png")
fakeRepository.uploadIconResult = Result.success(listOf(fakeIcon))
fakeRepository.existingIconsResult = Result.success(emptyList())
val result = evaluateAppUseCase(realInstalledApplication, 1)
Assert.assertTrue("Should return success", result.isSuccess)
}
private class FakeRepository : com.klee.sapio.domain.EvaluationRepository {
var uploadIconResult: Result> = Result.success(emptyList())
var existingIconsResult: Result> = Result.success(emptyList())
var addEvaluationResult: Result = Result.success(Unit)
override suspend fun listLatestEvaluations(pageNumber: Int): Result> =
Result.success(emptyList())
override suspend fun searchEvaluations(pattern: String): Result> =
Result.success(emptyList())
override suspend fun addEvaluation(evaluation: UploadEvaluation): Result =
addEvaluationResult
override suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int): Result =
Result.success(Unit)
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int): Result =
Result.success(null)
override suspend fun existingEvaluations(packageName: String): Result> =
Result.success(emptyList())
override suspend fun uploadIcon(packageName: String): Result> =
uploadIconResult
override suspend fun existingIcon(iconName: String): Result> =
existingIconsResult
override suspend fun deleteIcon(id: Int): Result = Result.success(Unit)
}
}
================================================
FILE: app/src/test/java/com/klee/sapio/EvaluateViewModelTest.kt
================================================
package com.klee.sapio
import android.os.Build
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.klee.sapio.data.system.DeviceConfiguration
import com.klee.sapio.domain.model.GmsType
import com.klee.sapio.domain.model.UserType
import com.klee.sapio.ui.viewmodel.EvaluateViewModel
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, sdk = [Build.VERSION_CODES.M])
class EvaluateViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private val appContext = RuntimeEnvironment.getApplication()
@Test
fun `uiState initializes with device gmsType and userType`() {
val vm = buildViewModel(gmsType = GmsType.MICROG, userType = UserType.SECURE)
assertEquals(GmsType.MICROG, vm.uiState.value.gmsType)
assertEquals(UserType.SECURE, vm.uiState.value.userType)
}
private fun buildViewModel(gmsType: Int = GmsType.BARE_AOSP, userType: Int = UserType.SECURE): EvaluateViewModel {
val fakeDeviceConfig = object : DeviceConfiguration(appContext) {
override fun getGmsType() = gmsType
override fun isUnsafe() = userType
}
return EvaluateViewModel(fakeDeviceConfig)
}
}
================================================
FILE: app/src/test/java/com/klee/sapio/EvaluationServiceTest.kt
================================================
package com.klee.sapio
import android.app.Application
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import com.klee.sapio.data.api.EvaluationApi
import com.klee.sapio.data.api.EvaluationService
import com.klee.sapio.data.system.Settings
import org.robolectric.Shadows
import com.klee.sapio.data.dto.Evaluation
import com.klee.sapio.data.dto.IconAnswer
import com.klee.sapio.data.dto.StrapiAnswer
import com.klee.sapio.data.dto.StrapiElement
import com.klee.sapio.data.dto.StrapiMeta
import com.klee.sapio.data.dto.UploadAnswer
import com.klee.sapio.data.dto.UploadEvaluation
import com.klee.sapio.data.dto.UploadEvaluationHeader
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
import java.util.Date
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, sdk = [Build.VERSION_CODES.P])
class EvaluationServiceTest {
@Mock
private lateinit var mockSettings: Settings
private lateinit var service: EvaluationService
@Before
fun setUp() {
MockitoAnnotations.openMocks(this)
val context = ApplicationProvider.getApplicationContext()
Shadows.shadowOf(context.packageManager).installPackage(
PackageInfo().apply {
packageName = "android"
applicationInfo = ApplicationInfo().apply { packageName = "android" }
}
)
service = EvaluationService(context)
setField("settings", mockSettings)
}
@Test
fun listLatestEvaluations_returnsEmptyOnHttpException() = runBlocking {
val errorBody = "error".toResponseBody("text/plain".toMediaTypeOrNull())
Mockito.`when`(mockSettings.getUnsafeConfigurationLevel()).thenReturn(0)
val api = object : EvaluationApi by failingApi() {
override suspend fun listLatestEvaluationsAsync(root: Int, pageNumber: Int): StrapiAnswer {
throw HttpException(Response.error(500, errorBody))
}
}
setApi(api)
val result = service.listLatestEvaluations(pageNumber = 1)
assertTrue(result.isFailure)
}
@Test
fun listLatestEvaluations_happyPathSorted() = runBlocking {
Mockito.`when`(mockSettings.getUnsafeConfigurationLevel()).thenReturn(1)
val evalNewer = StrapiElement(1, createEvaluation("AppOne", "pkg1", updatedAtOffset = 2))
val evalOlder = StrapiElement(2, createEvaluation("AppTwo", "pkg1", updatedAtOffset = 1))
val answer = StrapiAnswer(arrayListOf(evalOlder, evalNewer), StrapiMeta(null))
val api = object : EvaluationApi by failingApi() {
override suspend fun listLatestEvaluationsAsync(root: Int, pageNumber: Int): StrapiAnswer = answer
}
setApi(api)
val result = service.listLatestEvaluations(pageNumber = 0).getOrThrow()
assertEquals(2, result.size)
assertEquals("AppOne", result.first().name)
assertEquals("AppTwo", result.last().name)
}
@Test
fun listLatestEvaluations_sortsByUpdatedAtAcrossPackages() = runBlocking {
Mockito.`when`(mockSettings.getUnsafeConfigurationLevel()).thenReturn(1)
val older = StrapiElement(1, createEvaluation("Old", "pkg.old", updatedAtOffset = 1))
val newer = StrapiElement(2, createEvaluation("New", "pkg.new", updatedAtOffset = 5))
val answer = StrapiAnswer(arrayListOf(older, newer), StrapiMeta(null))
setApi(object : EvaluationApi by failingApi() {
override suspend fun listLatestEvaluationsAsync(root: Int, pageNumber: Int): StrapiAnswer = answer
})
val result = service.listLatestEvaluations(pageNumber = 0).getOrThrow()
assertEquals(listOf("pkg.new", "pkg.old"), result.map { it.packageName })
}
@Test
fun listLatestEvaluations_respectsPageNumberParameter() = runBlocking {
Mockito.`when`(mockSettings.getUnsafeConfigurationLevel()).thenReturn(1)
val page1 = StrapiAnswer(arrayListOf(StrapiElement(1, createEvaluation("P1", "p1"))), StrapiMeta(null))
val page2 = StrapiAnswer(arrayListOf(StrapiElement(2, createEvaluation("P2", "p2"))), StrapiMeta(null))
setApi(object : EvaluationApi by failingApi() {
override suspend fun listLatestEvaluationsAsync(root: Int, pageNumber: Int): StrapiAnswer =
if (pageNumber == 2) page2 else page1
})
val first = service.listLatestEvaluations(pageNumber = 1).getOrThrow()
val second = service.listLatestEvaluations(pageNumber = 2).getOrThrow()
assertEquals("p1", first.first().packageName)
assertEquals("p2", second.first().packageName)
}
@Test
fun searchEvaluation_returnsEmptyOnIOException() = runBlocking {
Mockito.`when`(mockSettings.getUnsafeConfigurationLevel()).thenReturn(0)
val api = object : EvaluationApi by failingApi() {
override suspend fun searchAsync(name: String, packageName: String, rooted: Int): StrapiAnswer {
throw IOException("boom")
}
}
setApi(api)
val result = service.searchEvaluation("pattern")
assertTrue(result.isFailure)
}
@Test
fun searchEvaluation_happyPathDistinctByPackage() = runBlocking {
Mockito.`when`(mockSettings.getUnsafeConfigurationLevel()).thenReturn(0)
val evalA = StrapiElement(1, createEvaluation("A", "pkgA"))
val evalADuplicate = StrapiElement(2, createEvaluation("A2", "pkgA"))
val answer = StrapiAnswer(arrayListOf(evalA, evalADuplicate), StrapiMeta(null))
val api = object : EvaluationApi by failingApi() {
override suspend fun searchAsync(name: String, packageName: String, rooted: Int): StrapiAnswer = answer
}
setApi(api)
val result = service.searchEvaluation("a").getOrThrow()
assertEquals(1, result.size)
assertEquals("pkgA", result.first().packageName)
assertEquals("A", result.first().name)
}
@Test
fun fetchEvaluation_returnsNullWhenNoData() = runBlocking {
val answer = StrapiAnswer(arrayListOf(), StrapiMeta(null))
val api = object : EvaluationApi by failingApi() {
override suspend fun getSingleEvaluationAsync(packageName: String, microG: Int, rooted: Int): StrapiAnswer = answer
}
setApi(api)
val result = service.fetchEvaluation("pkg", microG = 0, rooted = 0).getOrThrow()
assertNull(result)
}
@Test
fun existingEvaluations_returnsList() = runBlocking {
val eval = StrapiElement(10, createEvaluation("B", "pkgB"))
val answer = StrapiAnswer(arrayListOf(eval), StrapiMeta(null))
val api = object : EvaluationApi by failingApi() {
override suspend fun existingEvaluationsAsync(packageName: String): StrapiAnswer = answer
}
setApi(api)
val result = service.existingEvaluations("pkgB").getOrThrow()
assertEquals(1, result.size)
assertEquals(10, result.first().id)
assertEquals("pkgB", result.first().attributes.packageName)
}
@Test
fun existingIcon_returnsNullOnIOException() = runBlocking {
val api = object : EvaluationApi by failingApi() {
override suspend fun existingIconAsync(iconName: String): List {
throw IOException("fail")
}
}
setApi(api)
val result = service.existingIcon("icon.png")
assertTrue(result.isFailure)
}
@Test
fun existingIcon_returnsListOnSuccess() = runBlocking {
val answer = emptyList()
val api = object : EvaluationApi by failingApi() {
override suspend fun existingIconAsync(iconName: String): List = answer
}
setApi(api)
val result = service.existingIcon("icon.png").getOrThrow()
assertTrue(result.isEmpty())
}
@Test
fun fetchEvaluation_returnsFirstElement() = runBlocking {
val eval = StrapiElement(5, createEvaluation("PkgEval", "pkg.eval"))
val api = object : EvaluationApi by failingApi() {
override suspend fun getSingleEvaluationAsync(packageName: String, microG: Int, rooted: Int): StrapiAnswer =
StrapiAnswer(arrayListOf(eval), StrapiMeta(null))
}
setApi(api)
val result = service.fetchEvaluation("pkg.eval", 0, 0).getOrThrow()
assertEquals("pkg.eval", result?.packageName)
}
@Test
fun addEvaluation_returnsNullOnIOException() = runBlocking {
val api = object : EvaluationApi by failingApi() {
override suspend fun addEvaluation(evaluation: UploadEvaluationHeader): UploadAnswer {
throw IOException("add failed")
}
}
setApi(api)
val result = service.addEvaluation(UploadEvaluationHeader(UploadEvaluation("a", "p", 1, 1, 0, 0)))
assertTrue(result.isFailure)
}
@Test
fun addEvaluation_returnsResponseOnSuccess() = runBlocking {
val response = UploadAnswer(
StrapiElement(1, createEvaluation("Success", "pkg.s")),
StrapiMeta(null)
)
val api = object : EvaluationApi by failingApi() {
override suspend fun addEvaluation(evaluation: UploadEvaluationHeader): UploadAnswer = response
}
setApi(api)
val result = service.addEvaluation(UploadEvaluationHeader(UploadEvaluation("a", "p", 1, 1, 0, 0)))
assertTrue(result.isSuccess)
}
@Test
fun updateEvaluation_returnsNullOnIOException() = runBlocking {
val api = object : EvaluationApi by failingApi() {
override suspend fun updateEvaluation(evaluation: UploadEvaluationHeader, id: Int): UploadAnswer {
throw IOException("update failed")
}
}
setApi(api)
val result = service.updateEvaluation(UploadEvaluationHeader(UploadEvaluation("a", "p", 1, 1, 0, 0)), 1)
assertTrue(result.isFailure)
}
@Test
fun updateEvaluation_returnsResponseOnSuccess() = runBlocking {
val response = UploadAnswer(
StrapiElement(2, createEvaluation("Updated", "pkg.u")),
StrapiMeta(null)
)
val api = object : EvaluationApi by failingApi() {
override suspend fun updateEvaluation(evaluation: UploadEvaluationHeader, id: Int): UploadAnswer = response
}
setApi(api)
val result = service.updateEvaluation(UploadEvaluationHeader(UploadEvaluation("a", "p", 1, 1, 0, 0)), 2)
assertTrue(result.isSuccess)
}
@Test
fun uploadIcon_returnsResponseOnSuccess() = runBlocking {
val api = object : EvaluationApi by failingApi() {
override suspend fun addIcon(image: okhttp3.MultipartBody.Part): ArrayList =
arrayListOf(createIconAnswer())
}
setApi(api)
val result = service.uploadIcon("android")
assertEquals(1, result.getOrThrow().size)
}
@Test
fun deleteIcon_returnsResponseOnSuccess() = runBlocking {
val api = object : EvaluationApi by failingApi() {
override suspend fun deleteIcon(id: Int): IconAnswer = createIconAnswer()
}
setApi(api)
val result = service.deleteIcon(99)
assertTrue(result.isSuccess)
}
@Test
fun uploadIcon_returnsNullOnIOException() = runBlocking {
val api = object : EvaluationApi by failingApi() {
override suspend fun addIcon(image: okhttp3.MultipartBody.Part): ArrayList {
throw IOException("net down")
}
}
setApi(api)
val result = service.uploadIcon("android")
assertTrue(result.isFailure)
}
@Test
fun deleteIcon_returnsNullOnIOException() = runBlocking {
val api = object : EvaluationApi by failingApi() {
override suspend fun deleteIcon(id: Int): IconAnswer {
throw IOException("delete failed")
}
}
setApi(api)
val result = service.deleteIcon(123)
assertTrue(result.isFailure)
}
private fun setField(name: String, value: Any) {
org.robolectric.util.ReflectionHelpers.setField(service, name, value)
}
private fun setApi(api: EvaluationApi) {
setField("evaluationsApi", api)
}
private fun failingApi(): EvaluationApi = object : EvaluationApi {
override suspend fun listLatestEvaluationsAsync(root: Int, pageNumber: Int) =
throw NotImplementedError()
override suspend fun searchAsync(name: String, packageName: String, rooted: Int) =
throw NotImplementedError()
override suspend fun existingEvaluationsAsync(packageName: String) = throw NotImplementedError()
override suspend fun addEvaluation(evaluation: UploadEvaluationHeader) = throw NotImplementedError()
override suspend fun updateEvaluation(evaluation: UploadEvaluationHeader, id: Int) = throw NotImplementedError()
override suspend fun addIcon(image: okhttp3.MultipartBody.Part) = throw NotImplementedError()
override suspend fun existingIconAsync(iconName: String) = throw NotImplementedError()
override suspend fun deleteIcon(id: Int) = throw NotImplementedError()
override suspend fun getSingleEvaluationAsync(packageName: String, microG: Int, rooted: Int) =
throw NotImplementedError()
}
private fun createEvaluation(
name: String,
packageName: String,
updatedAtOffset: Long = 0
): Evaluation {
val date = Date(System.currentTimeMillis() + updatedAtOffset)
return Evaluation(
name = name,
packageName = packageName,
icon = null,
rating = 1,
microg = 0,
secure = 0,
updatedAt = date,
createdAt = date,
publishedAt = date,
versionName = "1.0"
)
}
private fun createIconAnswer(): IconAnswer {
val now = Date()
return IconAnswer(
id = 1,
name = "icon",
alternativeText = null,
caption = null,
width = 10,
height = 10,
formats = null,
hash = "hash",
ext = ".png",
mime = "image/png",
size = 1,
url = "http://localhost/icon.png",
previewUrl = null,
provider = null,
provider_metadata = null,
createdAt = now,
updatedAt = now
)
}
}
================================================
FILE: app/src/test/java/com/klee/sapio/FeedViewModelTest.kt
================================================
package com.klee.sapio
import android.os.Build
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.klee.sapio.domain.ListLatestEvaluationsUseCase
import com.klee.sapio.domain.EvaluationRepository
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.domain.model.EvaluationRecord
import com.klee.sapio.domain.model.Icon
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.domain.model.UploadEvaluation
import com.klee.sapio.ui.viewmodel.FeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, sdk = [Build.VERSION_CODES.M])
class FeedViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `refresh loads only page 1 on startup`() = runTest(dispatcher) {
val pagesRequested = mutableListOf()
val vm = FeedViewModel(useCaseReturningOnePage(pagesRequested))
advanceUntilIdle()
assertEquals(listOf(1), pagesRequested)
assertEquals(1, vm.uiState.value.items.size)
assertEquals("page-1", vm.uiState.value.items.first().name)
}
@Test
fun `loadNextPage appends the next page`() = runTest(dispatcher) {
val pagesRequested = mutableListOf()
val vm = FeedViewModel(useCaseReturningOnePage(pagesRequested))
advanceUntilIdle()
vm.loadNextPage()
advanceUntilIdle()
assertEquals(listOf(1, 2), pagesRequested)
assertEquals(2, vm.uiState.value.items.size)
assertEquals("page-1", vm.uiState.value.items[0].name)
assertEquals("page-2", vm.uiState.value.items[1].name)
}
@Test
fun `loadNextPage is ignored while initial load is in progress`() = runTest(dispatcher) {
val pagesRequested = mutableListOf()
val vm = FeedViewModel(useCaseReturningOnePage(pagesRequested))
// initial load is enqueued but hasn't run yet (isLoading = true from initial state)
vm.loadNextPage()
advanceUntilIdle()
// only page 1 should have been fetched
assertEquals(listOf(1), pagesRequested)
}
@Test
fun `loadNextPage stops when an empty page is returned`() = runTest(dispatcher) {
val pagesRequested = mutableListOf()
val vm = FeedViewModel(useCaseReturningEmptyAfter(page = 1, pagesRequested))
advanceUntilIdle()
vm.loadNextPage() // page 2 returns empty — hasMorePages becomes false
advanceUntilIdle()
vm.loadNextPage() // should be no-ops from here
vm.loadNextPage()
advanceUntilIdle()
assertEquals(listOf(1, 2), pagesRequested)
}
@Test
fun `refresh resets and reloads from page 1`() = runTest(dispatcher) {
val pagesRequested = mutableListOf()
val vm = FeedViewModel(useCaseReturningOnePage(pagesRequested))
advanceUntilIdle()
vm.loadNextPage()
advanceUntilIdle()
vm.refresh()
advanceUntilIdle()
assertEquals(listOf(1, 2, 1), pagesRequested)
assertEquals(1, vm.uiState.value.items.size)
assertEquals("page-1", vm.uiState.value.items.first().name)
}
@Test
fun `error during load sets hasError in state`() = runTest(dispatcher) {
val vm = FeedViewModel(useCaseAlwaysFailing())
advanceUntilIdle()
assertTrue(vm.uiState.value.hasError)
assertFalse(vm.uiState.value.isLoading)
}
// --- helpers ---
private fun useCaseReturningOnePage(tracker: MutableList): ListLatestEvaluationsUseCase {
return object : ListLatestEvaluationsUseCase(emptyRepository()) {
override suspend fun invoke(pageNumber: Int): Result> {
tracker.add(pageNumber)
return Result.success(listOf(eval("page-$pageNumber", pageNumber)))
}
}
}
private fun useCaseReturningEmptyAfter(
page: Int,
tracker: MutableList
): ListLatestEvaluationsUseCase {
return object : ListLatestEvaluationsUseCase(emptyRepository()) {
override suspend fun invoke(pageNumber: Int): Result> {
tracker.add(pageNumber)
return if (pageNumber <= page) {
Result.success(listOf(eval("page-$pageNumber", pageNumber)))
} else {
Result.success(emptyList())
}
}
}
}
private fun useCaseAlwaysFailing(): ListLatestEvaluationsUseCase {
return object : ListLatestEvaluationsUseCase(emptyRepository()) {
override suspend fun invoke(pageNumber: Int): Result> {
return Result.failure(RuntimeException("network error"))
}
}
}
private fun emptyRepository(): EvaluationRepository = object : EvaluationRepository {
override suspend fun listLatestEvaluations(pageNumber: Int): Result> = Result.success(emptyList())
override suspend fun searchEvaluations(pattern: String): Result> = Result.success(emptyList())
override suspend fun addEvaluation(evaluation: UploadEvaluation): Result = Result.success(Unit)
override suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int): Result = Result.success(Unit)
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int): Result = Result.success(null)
override suspend fun existingEvaluations(packageName: String): Result> = Result.success(emptyList())
override suspend fun uploadIcon(packageName: String): Result> = Result.success(emptyList())
override suspend fun existingIcon(iconName: String): Result> = Result.success(emptyList())
override suspend fun deleteIcon(id: Int): Result = Result.success(Unit)
}
private fun eval(name: String, rating: Int) = Evaluation(
name = name,
packageName = "pkg$rating",
iconUrl = null,
rating = rating,
microg = 1,
secure = 1,
updatedAt = null,
createdAt = null,
publishedAt = null,
versionName = null
)
}
================================================
FILE: app/src/test/java/com/klee/sapio/InstalledApplicationsRepositoryTest.kt
================================================
package com.klee.sapio
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.drawable.Drawable
import android.os.Build
import com.klee.sapio.data.repository.InstalledApplicationsRepository
import junit.framework.TestCase.assertEquals
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyString
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, sdk = [Build.VERSION_CODES.N])
class InstalledApplicationsRepositoryTest {
private lateinit var repository: InstalledApplicationsRepository
@Mock
private lateinit var mockedPackageManager: PackageManager
@Mock
private lateinit var mockedContext: Context
@Mock
private lateinit var mockedDrawable: Drawable
private lateinit var fakeRegularApplicationInfo: ApplicationInfo
private lateinit var fakeSystemApplicationInfo: ApplicationInfo
private lateinit var fakeGmsApp: ApplicationInfo
private var fakeListApplicationInfo: List? = null
@Before
fun setUp() {
MockitoAnnotations.openMocks(this)
repository = InstalledApplicationsRepository(mockedContext)
fakeRegularApplicationInfo = ApplicationInfo().apply {
packageName = "fake.package.name.one"
name = "FakeApplicationOne"
}
fakeSystemApplicationInfo = ApplicationInfo().apply {
packageName = "fake.package.name.one"
name = "FakeApplicationTwo"
flags = ApplicationInfo.FLAG_SYSTEM
}
fakeGmsApp = ApplicationInfo().apply {
packageName = "fake.package.gms"
name = "FakeApplicationThree"
}
fakeListApplicationInfo = mutableListOf(
fakeRegularApplicationInfo,
fakeSystemApplicationInfo,
fakeGmsApp
)
Mockito.`when`(mockedContext.packageManager).thenReturn(mockedPackageManager)
Mockito.`when`(mockedPackageManager.queryIntentActivities(any(Intent::class.java), eq(0)))
.thenReturn(fakeListApplicationInfo!!.map { makeResolveInfo(it) })
Mockito.`when`(mockedPackageManager.getApplicationLabel(eq(fakeRegularApplicationInfo)))
.thenReturn("FakeApplicationOne")
Mockito.`when`(mockedPackageManager.getApplicationLabel(eq(fakeSystemApplicationInfo)))
.thenReturn("FakeApplicationTwo")
Mockito.`when`(mockedPackageManager.getDrawable(anyString(), anyInt(), any()))
.thenReturn(mockedDrawable)
}
private fun makeResolveInfo(appInfo: ApplicationInfo): ResolveInfo {
val activityInfo = ActivityInfo().apply { applicationInfo = appInfo }
return ResolveInfo().apply { this.activityInfo = activityInfo }
}
@Test
fun test_isSystemAppWithRegularApp() {
Assert.assertEquals(
"App status error",
false,
repository.isSystemApp(fakeRegularApplicationInfo)
)
}
@Test
fun test_isSystemAppWithSystemApp() {
Assert.assertEquals(
"App status error",
true,
repository.isSystemApp(fakeSystemApplicationInfo)
)
}
@Test
fun test_isGmsWithRegularApp() {
Assert.assertEquals(
"App status error",
false,
repository.isGmsRelated(fakeRegularApplicationInfo)
)
}
@Test
fun test_isGmsWithGmsApp() {
Assert.assertEquals(
"App status error",
true,
repository.isGmsRelated(fakeGmsApp)
)
}
@Test
fun test_isGmsWithPlayStoreApp() {
val playStoreApp = ApplicationInfo().apply {
packageName = "com.android.vending"
name = "PlayStore"
}
Assert.assertTrue("Play Store should be treated as GMS", repository.isGmsRelated(playStoreApp))
}
@Test
fun test_listApplicationCheckListSize() {
val list = repository.listInstalledApplications()
assertEquals("Wrong list size.", 1, list.size)
}
@Test
fun test_listApplicationCheckElement() {
val list = repository.listInstalledApplications()
assertEquals(
"Package name are not the same.",
fakeRegularApplicationInfo.packageName,
list[0].packageName)
}
@Test
fun test_getApplicationFromPackageName_found() {
val result = repository.getInstalledApplication(fakeRegularApplicationInfo.packageName)
Assert.assertNotNull("Should find the application", result)
Assert.assertEquals("Package names should match", fakeRegularApplicationInfo.packageName, result?.packageName)
}
@Test
fun test_getApplicationFromPackageName_notFound() {
val result = repository.getInstalledApplication("non.existent.package")
Assert.assertNull("Should return null for non-existent package", result)
}
@Test
fun test_getAppList_sorting() {
val fakeAppZ = ApplicationInfo().apply {
packageName = "fake.package.name.z"
name = "ZebraApp"
}
val appsWithZ = mutableListOf(
fakeRegularApplicationInfo,
fakeSystemApplicationInfo,
fakeGmsApp,
fakeAppZ
)
Mockito.`when`(mockedPackageManager.queryIntentActivities(any(Intent::class.java), eq(0)))
.thenReturn(appsWithZ.map { makeResolveInfo(it) })
Mockito.`when`(mockedPackageManager.getApplicationLabel(eq(fakeRegularApplicationInfo)))
.thenReturn("FakeApplicationOne")
Mockito.`when`(mockedPackageManager.getApplicationLabel(eq(fakeAppZ)))
.thenReturn("ZebraApp")
val list = repository.listInstalledApplications()
Assert.assertEquals("Should have 2 apps", 2, list.size)
Assert.assertEquals("First app should be FakeApplicationOne", "fakeapplicationone", list[0].name.lowercase())
Assert.assertEquals("Second app should be ZebraApp", "zebraapp", list[1].name.lowercase())
}
@Test
fun test_getAppList_emptyListWhenNoApps() {
Mockito.`when`(mockedPackageManager.queryIntentActivities(any(Intent::class.java), eq(0)))
.thenReturn(emptyList())
val list = repository.listInstalledApplications()
Assert.assertTrue("List should be empty when no apps are installed", list.isEmpty())
}
@Test
fun test_getAppList_filtersSystemAndGmsApps() {
fakeSystemApplicationInfo.flags = ApplicationInfo.FLAG_SYSTEM
fakeGmsApp.packageName = "com.google.gms"
Mockito.`when`(mockedPackageManager.queryIntentActivities(any(Intent::class.java), eq(0)))
.thenReturn(listOf(fakeRegularApplicationInfo, fakeSystemApplicationInfo, fakeGmsApp).map { makeResolveInfo(it) })
Mockito.`when`(mockedPackageManager.getApplicationLabel(eq(fakeRegularApplicationInfo)))
.thenReturn("FakeApplicationOne")
Mockito.`when`(mockedPackageManager.getApplicationLabel(eq(fakeSystemApplicationInfo)))
.thenReturn("SystemApp")
Mockito.`when`(mockedPackageManager.getApplicationLabel(eq(fakeGmsApp)))
.thenReturn("GmsApp")
val list = repository.listInstalledApplications()
Assert.assertEquals(1, list.size)
Assert.assertEquals(fakeRegularApplicationInfo.packageName, list.first().packageName)
}
}
================================================
FILE: app/src/test/java/com/klee/sapio/LoadingViewModelTest.kt
================================================
package com.klee.sapio
import android.os.Build
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.klee.sapio.data.system.DeviceConfiguration
import com.klee.sapio.domain.EvaluateAppUseCase
import com.klee.sapio.domain.InstalledApplicationsDataSource
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.ui.state.EvaluateEvent
import com.klee.sapio.ui.viewmodel.LoadingViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, sdk = [Build.VERSION_CODES.M])
class LoadingViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private val dispatcher = StandardTestDispatcher()
private val appContext = RuntimeEnvironment.getApplication()
private val fakeApp = InstalledApplication(
name = "Test App",
packageName = "com.test.app"
)
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `submit emits NavigateToSuccess on success`() = runTest(dispatcher) {
val vm = buildViewModel(app = fakeApp, useCaseResult = Result.success(Unit))
val events = mutableListOf()
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
vm.events.collect { events.add(it) }
}
vm.submit("com.test.app", "Test App", rating = 1)
advanceUntilIdle()
val event = events.first()
assertTrue(event is EvaluateEvent.NavigateToSuccess)
assertEquals("com.test.app", (event as EvaluateEvent.NavigateToSuccess).packageName)
assertEquals("Test App", event.appName)
job.cancel()
}
@Test
fun `submit emits ShowError on failure`() = runTest(dispatcher) {
val vm = buildViewModel(
app = fakeApp,
useCaseResult = Result.failure(IllegalStateException("error"))
)
val events = mutableListOf()
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
vm.events.collect { events.add(it) }
}
vm.submit("com.test.app", "Test App", rating = 1)
advanceUntilIdle()
assertTrue(events.first() is EvaluateEvent.ShowError)
job.cancel()
}
@Test
fun `submit emits ShowError when app not found`() = runTest(dispatcher) {
val vm = buildViewModel(app = null, useCaseResult = Result.success(Unit))
val events = mutableListOf()
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
vm.events.collect { events.add(it) }
}
vm.submit("com.unknown.app", "Unknown", rating = 1)
advanceUntilIdle()
assertTrue(events.first() is EvaluateEvent.ShowError)
job.cancel()
}
private fun buildViewModel(
app: InstalledApplication? = fakeApp,
useCaseResult: Result = Result.success(Unit)
): LoadingViewModel {
val fakeRepo = object : com.klee.sapio.domain.EvaluationRepository {
override suspend fun listLatestEvaluations(pageNumber: Int) = Result.success(emptyList())
override suspend fun searchEvaluations(pattern: String) = Result.success(emptyList())
override suspend fun addEvaluation(evaluation: com.klee.sapio.domain.model.UploadEvaluation) = Result.success(Unit)
override suspend fun updateEvaluation(evaluation: com.klee.sapio.domain.model.UploadEvaluation, id: Int) = Result.success(Unit)
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int) = Result.success(null)
override suspend fun existingEvaluations(packageName: String) = Result.success(emptyList())
override suspend fun uploadIcon(packageName: String) = Result.success(emptyList())
override suspend fun existingIcon(iconName: String) = Result.success(emptyList())
override suspend fun deleteIcon(id: Int) = Result.success(Unit)
}
val fakeDeviceConfig = object : DeviceConfiguration(appContext) {
override fun getGmsType() = 0
override fun isUnsafe() = 0
}
val fakeInstalledAppsRepo = object : InstalledApplicationsDataSource {
override fun listInstalledApplications(): List {
return emptyList()
}
override fun getInstalledApplication(packageName: String): InstalledApplication? {
return app
}
}
val fakeUseCase = object : EvaluateAppUseCase(fakeRepo, fakeDeviceConfig) {
override suspend fun invoke(a: InstalledApplication, rating: Int) = useCaseResult
}
return LoadingViewModel(fakeInstalledAppsRepo, fakeUseCase)
}
}
================================================
FILE: app/src/test/java/com/klee/sapio/RatingTest.kt
================================================
package com.klee.sapio
import com.klee.sapio.ui.model.Rating
import org.junit.Assert.assertEquals
import org.junit.Test
class RatingTest {
@Test
fun `create returns green circle for GOOD`() {
val rating = Rating.create(Rating.GOOD)
assertEquals(Rating.GOOD, rating.value)
assertEquals(String(Character.toChars(Rating.GREEN_CIRCLE_EMOJI)), rating.text)
}
@Test
fun `create returns yellow circle for AVERAGE`() {
val rating = Rating.create(Rating.AVERAGE)
assertEquals(Rating.AVERAGE, rating.value)
assertEquals(String(Character.toChars(Rating.YELLOW_CIRCLE_EMOJI)), rating.text)
}
@Test
fun `create returns red circle for BAD`() {
val rating = Rating.create(Rating.BAD)
assertEquals(Rating.BAD, rating.value)
assertEquals(String(Character.toChars(Rating.RED_CIRCLE_EMOJI)), rating.text)
}
@Test
fun `create defaults to BAD when rating is unknown`() {
val rating = Rating.create(999)
assertEquals(Rating.BAD, rating.value)
assertEquals(String(Character.toChars(Rating.RED_CIRCLE_EMOJI)), rating.text)
}
}
================================================
FILE: app/src/test/java/com/klee/sapio/SapioApplicationTest.kt
================================================
package com.klee.sapio
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, application = SapioApplication::class)
class SapioApplicationTest {
@Test
fun onCreate_shouldApplyDynamicColorsWithoutCrashing() {
val app = ApplicationProvider.getApplicationContext()
app.onCreate()
assertNotNull(app)
}
}
================================================
FILE: app/src/test/java/com/klee/sapio/SearchViewModelTest.kt
================================================
package com.klee.sapio
import android.os.Build
import com.klee.sapio.domain.SearchEvaluationUseCase
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.domain.model.EvaluationRecord
import com.klee.sapio.domain.model.Icon
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.domain.model.UploadEvaluation
import com.klee.sapio.ui.viewmodel.SearchViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
@ExperimentalCoroutinesApi
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, sdk = [Build.VERSION_CODES.M])
class SearchViewModelTest {
private lateinit var searchViewModel: SearchViewModel
private val testDispatcher = UnconfinedTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
searchViewModel = SearchViewModel(FakeSearchUseCase(emptyList()))
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun test_searchViewModel_initialState() = runTest {
// Test that the ViewModel initializes correctly
val initialState = searchViewModel.uiState.value
Assert.assertTrue("Initial state should be empty", initialState.items.isEmpty())
}
@Test
fun test_searchViewModel_flowBehavior() = runTest {
// Test basic flow behavior - this is a simple test to verify the ViewModel works
// without needing to mock the complex use case
val initialState = searchViewModel.uiState.value
Assert.assertNotNull("State should not be null", initialState)
}
@Test
fun test_search_updates_state_and_calls_onError_when_empty() = runTest {
val fakeUseCase = FakeSearchUseCase(emptyList())
val viewModel = SearchViewModel(fakeUseCase)
var errored = false
viewModel.searchApplication("pattern") { errored = true }
advanceUntilIdle()
val result = viewModel.uiState.value.items
Assert.assertTrue(errored)
Assert.assertTrue(result.isEmpty())
}
@Test
fun test_search_updates_state_with_results() = runTest {
val expected = listOf(
Evaluation("Name", "pkg", iconUrl = null, rating = 1, microg = 1, secure = 1, updatedAt = null, createdAt = null, publishedAt = null, versionName = null)
)
val fakeUseCase = FakeSearchUseCase(expected)
val viewModel = SearchViewModel(fakeUseCase)
var errored = false
viewModel.searchApplication("pattern") { errored = true }
advanceUntilIdle()
val result = viewModel.uiState.value.items
Assert.assertFalse(errored)
Assert.assertEquals(expected, result)
}
private class FakeSearchUseCase(private val result: List) : SearchEvaluationUseCase(object : com.klee.sapio.domain.EvaluationRepository {
override suspend fun listLatestEvaluations(pageNumber: Int): Result> =
Result.success(emptyList())
override suspend fun searchEvaluations(pattern: String): Result> =
Result.success(emptyList())
override suspend fun addEvaluation(evaluation: UploadEvaluation): Result = Result.success(Unit)
override suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int): Result =
Result.success(Unit)
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int): Result =
Result.success(null)
override suspend fun existingEvaluations(packageName: String): Result> =
Result.success(emptyList())
override suspend fun uploadIcon(packageName: String): Result> =
Result.success(emptyList())
override suspend fun existingIcon(iconName: String): Result> =
Result.success(emptyList())
override suspend fun deleteIcon(id: Int): Result = Result.success(Unit)
}) {
override suspend operator fun invoke(pattern: String): Result> = Result.success(result)
}
}
================================================
FILE: app/src/test/java/com/klee/sapio/SettingsTest.kt
================================================
package com.klee.sapio
import android.os.Build
import androidx.preference.PreferenceManager
import com.klee.sapio.data.system.Settings
import com.klee.sapio.domain.model.UserType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, sdk = [Build.VERSION_CODES.M])
class SettingsTest {
private lateinit var settings: Settings
@Before
fun setUp() {
val context = RuntimeEnvironment.getApplication()
// Start with clean preferences for each test
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
settings = Settings(context)
}
@Test
fun `isUnsafeConfigurationEnabled returns false by default`() {
assertFalse(settings.isUnsafeConfigurationEnabled())
assertEquals(UserType.SECURE, settings.getUnsafeConfigurationLevel())
}
@Test
fun `isUnsafeConfigurationEnabled reflects stored preference`() {
val context = RuntimeEnvironment.getApplication()
PreferenceManager.getDefaultSharedPreferences(context)
.edit()
.putBoolean("show_root", true)
.commit()
assertTrue(settings.isUnsafeConfigurationEnabled())
assertEquals(UserType.UNSAFE, settings.getUnsafeConfigurationLevel())
}
}
================================================
FILE: app/src/test/java/com/klee/sapio/SystemPropertyReaderTest.kt
================================================
package com.klee.sapio
import android.os.Build
import com.klee.sapio.data.system.SystemPropertyReader
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.Config.NONE
import org.robolectric.util.ReflectionHelpers
@RunWith(RobolectricTestRunner::class)
@Config(manifest = NONE, sdk = [Build.VERSION_CODES.P])
class SystemPropertyReaderTest {
private lateinit var reader: SystemPropertyReader
@org.junit.Before
fun setUp() {
reader = SystemPropertyReader()
}
@Test
fun read_returnsValueWhenPropertyIsSet() {
ReflectionHelpers.callStaticMethod(
Class.forName("android.os.SystemProperties"),
"set",
ReflectionHelpers.ClassParameter.from(String::class.java, "test.prop"),
ReflectionHelpers.ClassParameter.from(String::class.java, "value123")
)
assertEquals("value123", reader.read("test.prop"))
}
@Test
fun read_returnsEmptyStringWhenPropertyIsMissing() {
assertEquals("", reader.read("missing.prop"))
}
@Test
fun read_returnsEmptyStringOnIllegalArgument() {
// Swap out the cached getMethod to one that always throws IllegalArgumentException
val failingMethod = Class.forName("com.klee.sapio.SystemPropertyReaderTestKt")
.getDeclaredMethod("throwIllegal", String::class.java, String::class.java)
org.robolectric.util.ReflectionHelpers.setField(reader, "getMethod\$delegate", lazy { failingMethod })
assertEquals("", reader.read("any.prop"))
}
}
fun throwIllegal(name: String, def: String): String {
throw IllegalArgumentException("boom")
}
================================================
FILE: app/src/test/java/com/klee/sapio/data/local/EvaluationDaoTest.kt
================================================
package com.klee.sapio.data.local
import android.os.Build
import androidx.room.Room
import java.util.Date
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.N])
class EvaluationDaoTest {
private lateinit var database: AppDatabase
private lateinit var evaluationDao: EvaluationDao
private lateinit var iconDao: IconDao
@Before
fun setUp() {
database = Room.inMemoryDatabaseBuilder(
RuntimeEnvironment.getApplication(),
AppDatabase::class.java
).allowMainThreadQueries().build()
evaluationDao = database.evaluationDao()
iconDao = database.iconDao()
}
@After
fun tearDown() {
database.close()
}
@Test
fun listLatestEvaluations_ordersByUpdatedAt() = runTest {
val older = EvaluationEntity(
name = "Old",
packageName = "com.old",
iconUrl = null,
rating = 1,
microg = 0,
secure = 1,
updatedAt = Date(100),
createdAt = Date(50),
publishedAt = Date(60),
versionName = "1.0",
cachedAt = 1
)
val newer = EvaluationEntity(
name = "New",
packageName = "com.new",
iconUrl = null,
rating = 2,
microg = 1,
secure = 0,
updatedAt = Date(200),
createdAt = Date(80),
publishedAt = Date(90),
versionName = "1.1",
cachedAt = 1
)
evaluationDao.upsertAll(listOf(older, newer))
val results = evaluationDao.listLatestEvaluations(limit = 1, offset = 0)
assertEquals(1, results.size)
assertEquals("com.new", results.first().packageName)
}
@Test
fun searchEvaluations_matchesNameOrPackage() = runTest {
val target = EvaluationEntity(
name = "Target App",
packageName = "com.target.app",
iconUrl = null,
rating = 1,
microg = 0,
secure = 1,
updatedAt = Date(100),
createdAt = Date(50),
publishedAt = Date(60),
versionName = "1.0",
cachedAt = 1
)
val other = EvaluationEntity(
name = "Other",
packageName = "com.other",
iconUrl = null,
rating = 1,
microg = 0,
secure = 1,
updatedAt = Date(100),
createdAt = Date(50),
publishedAt = Date(60),
versionName = "1.0",
cachedAt = 1
)
evaluationDao.upsertAll(listOf(target, other))
val results = evaluationDao.searchEvaluations("%target%")
assertEquals(1, results.size)
assertEquals("com.target.app", results.first().packageName)
}
@Test
fun getEvaluation_usesCompositeKey() = runTest {
val target = EvaluationEntity(
name = "Keyed",
packageName = "com.keyed",
iconUrl = null,
rating = 1,
microg = 1,
secure = 0,
updatedAt = Date(100),
createdAt = Date(50),
publishedAt = Date(60),
versionName = "1.0",
cachedAt = 1
)
evaluationDao.upsertAll(listOf(target))
val result = evaluationDao.getEvaluation("com.keyed", microg = 1, secure = 0)
assertNotNull(result)
assertEquals("com.keyed", result?.packageName)
}
@Test
fun findIconByName_returnsAllMatchingName() = runTest {
val expired = IconEntity(
id = 1,
name = "com.app.png",
url = "/expired.png",
cachedAt = 10
)
val fresh = IconEntity(
id = 2,
name = "com.app.png",
url = "/fresh.png",
cachedAt = 200
)
iconDao.upsertAll(listOf(expired, fresh))
val results = iconDao.findByName("com.app.png")
assertEquals(2, results.size)
assertTrue(results.any { it.url.contains("fresh") })
}
}
================================================
FILE: app/src/test/java/com/klee/sapio/data/repository/EvaluationRepositoryImplTest.kt
================================================
package com.klee.sapio.data.repository
import android.os.Build
import androidx.room.Room
import com.klee.sapio.data.api.EvaluationService
import com.klee.sapio.data.dto.Evaluation
import com.klee.sapio.data.dto.IconAnswer
import com.klee.sapio.data.local.AppDatabase
import com.klee.sapio.data.local.EvaluationDao
import com.klee.sapio.data.local.IconDao
import com.klee.sapio.data.local.EvaluationEntity
import com.klee.sapio.data.local.IconEntity
import java.util.Date
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.N])
class EvaluationRepositoryImplTest {
private lateinit var database: AppDatabase
private lateinit var evaluationDao: EvaluationDao
private lateinit var iconDao: IconDao
private lateinit var evaluationService: EvaluationService
private lateinit var repository: EvaluationRepositoryImpl
@Before
fun setUp() {
database = Room.inMemoryDatabaseBuilder(
RuntimeEnvironment.getApplication(),
AppDatabase::class.java
).allowMainThreadQueries().build()
evaluationDao = database.evaluationDao()
iconDao = database.iconDao()
evaluationService = Mockito.mock(EvaluationService::class.java)
repository = EvaluationRepositoryImpl(evaluationService, evaluationDao, iconDao)
}
@After
fun tearDown() {
database.close()
}
@Test
fun listLatestEvaluations_remoteSuccessCachesResults() = runTest {
val remote = listOf(
Evaluation(
name = "App One",
packageName = "com.app.one",
icon = null,
rating = 1,
microg = 1,
secure = 0,
updatedAt = Date(2),
createdAt = Date(1),
publishedAt = Date(1),
versionName = "1.0"
)
)
Mockito.`when`(evaluationService.listLatestEvaluations(1))
.thenReturn(Result.success(remote))
val result = repository.listLatestEvaluations(1)
assertTrue(result.isSuccess)
assertEquals(1, result.getOrThrow().size)
assertEquals(1, evaluationDao.listLatestEvaluations(10, 0).size)
}
@Test
fun listLatestEvaluations_remoteFailureFallsBackToCache() = runTest {
val cached = Evaluation(
name = "Cached App",
packageName = "com.cached.app",
icon = null,
rating = 2,
microg = 0,
secure = 1,
updatedAt = Date(2),
createdAt = Date(1),
publishedAt = Date(1),
versionName = "1.0"
)
evaluationDao.upsertAll(listOf(
EvaluationEntity(
name = cached.name,
packageName = cached.packageName,
iconUrl = null,
rating = cached.rating,
microg = cached.microg,
secure = cached.secure,
updatedAt = cached.updatedAt,
createdAt = cached.createdAt,
publishedAt = cached.publishedAt,
versionName = cached.versionName,
cachedAt = System.currentTimeMillis()
)
))
Mockito.`when`(evaluationService.listLatestEvaluations(1))
.thenReturn(Result.failure(IllegalStateException("Network error")))
val result = repository.listLatestEvaluations(1)
assertTrue(result.isSuccess)
assertEquals("com.cached.app", result.getOrThrow().first().packageName)
}
@Test
fun existingIcon_networkFirstCachesWhenSuccessful() = runTest {
val now = System.currentTimeMillis()
iconDao.upsertAll(
listOf(IconEntity(id = 12, name = "com.app.one.png", url = "/icon.png", cachedAt = now))
)
val remote = listOf(
iconAnswer(
id = 13,
name = "com.app.one.png",
url = "/remote.png"
)
)
Mockito.`when`(evaluationService.existingIcon("com.app.one.png"))
.thenReturn(Result.success(remote))
val result = repository.existingIcon("com.app.one.png")
assertTrue(result.isSuccess)
assertEquals("${EvaluationService.BASE_URL}/remote.png", result.getOrThrow().first().url)
}
@Test
fun existingIcon_fallsBackToCacheOnFailure() = runTest {
val cachedAt = System.currentTimeMillis() - 1000
iconDao.upsertAll(
listOf(IconEntity(id = 22, name = "com.app.two.png", url = "/old.png", cachedAt = cachedAt))
)
Mockito.`when`(evaluationService.existingIcon("com.app.two.png"))
.thenReturn(Result.failure(IllegalStateException("Network error")))
val result = repository.existingIcon("com.app.two.png")
assertTrue(result.isSuccess)
assertEquals("${EvaluationService.BASE_URL}/old.png", result.getOrThrow().first().url)
}
private fun iconAnswer(id: Int, name: String, url: String): IconAnswer {
return IconAnswer(
id = id,
name = name,
alternativeText = null,
caption = null,
width = 64,
height = 64,
formats = null,
hash = "hash",
ext = ".png",
mime = "image/png",
size = 10,
url = url,
previewUrl = null,
provider = "local",
provider_metadata = null,
createdAt = Date(0),
updatedAt = Date(0)
)
}
}
================================================
FILE: build.gradle
================================================
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
dependencies {
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.54'
}
}
plugins {
id 'com.android.application' version '8.11.2' apply false
id 'com.android.library' version '8.11.2' apply false
id 'org.jetbrains.kotlin.android' version '2.1.0' apply false
id 'org.jetbrains.kotlin.jvm' version '2.1.0' apply false
alias libs.plugins.detekt
alias libs.plugins.compose.compiler apply false
}
task clean(type: Delete) {
delete rootProject.buildDir
}
subprojects {
detekt {
source = files(
"src/main/java"
)
toolVersion = "1.23.1"
config.setFrom("detekt.yml")
baseline = file("detekt-baseline.xml")
parallel = false
buildUponDefaultConfig = true
allRules = false
disableDefaultRuleSets = false
debug = false
ignoreFailures = false
basePath = projectDir
}
}
dependencies {
detektPlugins libs.detekt.formatting
}
================================================
FILE: data/build.gradle
================================================
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
compileSdk 36
defaultConfig {
minSdk 21
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
namespace 'com.klee.sapio.data'
}
dependencies {
implementation project(':domain')
implementation libs.androidx.core.ktx
implementation libs.kotlinx.coroutines.android
implementation libs.room.runtime
implementation libs.room.ktx
kapt libs.room.compiler
implementation libs.hilt.android
kapt libs.hilt.compiler
implementation libs.rootbeer.lib
implementation libs.retrofit
implementation libs.converter.jackson
implementation libs.logging.interceptor
implementation libs.retrofit2.kotlin.coroutines.adapter
implementation libs.androidx.preference.ktx
implementation libs.androidx.work.runtime.ktx
testImplementation libs.junit
testImplementation libs.mockito.core
testImplementation libs.mockito.inline
testImplementation libs.robolectric
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.androidx.arch.core.testing
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/api/RetrofitClient.kt
================================================
package com.klee.sapio.data.api
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.AdaptiveIconDrawable
import android.util.Log
import androidx.core.graphics.drawable.toBitmap
import com.klee.sapio.data.dto.Evaluation
import com.klee.sapio.data.dto.IconAnswer
import com.klee.sapio.data.dto.StrapiAnswer
import com.klee.sapio.data.dto.StrapiElement
import com.klee.sapio.data.dto.UploadAnswer
import com.klee.sapio.data.dto.UploadEvaluationHeader
import com.klee.sapio.domain.AppSettings
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.withTimeout
import okhttp3.Cache
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.ByteArrayOutputStream
import javax.inject.Inject
interface EvaluationApi {
@GET("sapio-applications?pagination[pageSize]=20&sort=updatedAt:Desc&populate[icon]=*")
suspend fun listLatestEvaluationsAsync(
@Query("filters[rooted][\$lte]") root: Int,
@Query("pagination[page]") pageNumber: Int
): StrapiAnswer
@GET("sapio-applications?sort=name&populate[icon]=*")
suspend fun searchAsync(
@Query("filters[\$or][0][name][\$contains]") name: String,
@Query("filters[\$or][1][packageName][\$contains]") packageName: String,
@Query("filters[\$and][2][rooted][\$lte]") rooted: Int
): StrapiAnswer
@GET("sapio-applications?")
suspend fun existingEvaluationsAsync(
@Query("filters[packageName][\$eq]") packageName: String
): StrapiAnswer
@Headers("Content-Type: application/json")
@POST("sapio-applications")
suspend fun addEvaluation(@Body evaluation: UploadEvaluationHeader): UploadAnswer
@Headers("Content-Type: application/json")
@PUT("sapio-applications/{id}")
suspend fun updateEvaluation(
@Body evaluation: UploadEvaluationHeader,
@Path(value = "id", encoded = false) id: Int
): UploadAnswer
@Multipart
@POST("upload")
suspend fun addIcon(@Part image: MultipartBody.Part): ArrayList
@GET("upload/files?sort=updatedAt")
suspend fun existingIconAsync(
@Query("filters[name][\$eq]") iconName: String
): List
@DELETE("upload/files/{id}")
suspend fun deleteIcon(
@Path(value = "id", encoded = false) id: Int
): IconAnswer
@GET("sapio-applications?sort=updatedAt:Desc")
suspend fun getSingleEvaluationAsync(
@Query("filters[\$and][0][packageName][\$eq]") packageName: String,
@Query("filters[\$and][1][microG][\$contains]") microG: Int,
@Query("filters[\$and][2][rooted][\$contains]") rooted: Int
): StrapiAnswer
}
open class EvaluationService @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
const val BASE_URL = "https://server.sapio.ovh"
const val COMPRESSION_QUALITY = 100
const val UPLOAD_TIMEOUT_MS: Long = 10000
const val CACHE_MAX_SIZE = 10 * 1024 * 1024L
}
@Inject
lateinit var settings: AppSettings
private var retrofit: Retrofit
private var evaluationsApi: EvaluationApi
init {
val okHttpClient = OkHttpClient()
.newBuilder()
.cache(Cache(context.cacheDir, CACHE_MAX_SIZE))
.build()
retrofit = Retrofit.Builder()
.client(okHttpClient)
.baseUrl("$BASE_URL/api/")
.addConverterFactory(JacksonConverterFactory.create())
.build()
evaluationsApi = retrofit.create(EvaluationApi::class.java)
}
open suspend fun listLatestEvaluations(pageNumber: Int): Result> =
runCatching {
val strapiAnswer = evaluationsApi.listLatestEvaluationsAsync(
settings.getUnsafeConfigurationLevel(),
pageNumber
)
strapiAnswer.data.map { it.attributes }
.sortedByDescending { it.updatedAt }
}.onFailure { exception ->
if (exception is HttpException) {
Log.i("EvaluationService", "HttpException: $exception")
}
}
open suspend fun searchEvaluation(pattern: String): Result> =
runCatching {
val strapiAnswer = evaluationsApi.searchAsync(
pattern,
pattern,
settings.getUnsafeConfigurationLevel()
)
strapiAnswer.data.map { it.attributes }
.sortedWith(
compareByDescending { it.icon != null }
.thenByDescending { it.updatedAt }
)
.distinctBy { it.packageName }
}
open suspend fun existingEvaluations(packageName: String): Result> =
runCatching {
val strapiAnswer = evaluationsApi.existingEvaluationsAsync(packageName)
strapiAnswer.data.toList()
}
open suspend fun addEvaluation(app: UploadEvaluationHeader): Result =
runCatching {
evaluationsApi.addEvaluation(app)
Unit
}
open suspend fun updateEvaluation(app: UploadEvaluationHeader, id: Int): Result =
runCatching {
evaluationsApi.updateEvaluation(app, id)
Unit
}
open suspend fun uploadIcon(packageName: String): Result> {
return runCatching {
val appInfo = context.packageManager.getApplicationInfo(packageName, 0)
val drawable = appInfo.loadUnbadgedIcon(context.packageManager) as AdaptiveIconDrawable
val bytes = fromDrawableToByArray(drawable)
val requestBody = bytes.toRequestBody(null, 0, bytes.size)
val image = MultipartBody.Part.createFormData(
"files",
"$packageName.png",
requestBody
)
withTimeout(UPLOAD_TIMEOUT_MS) {
evaluationsApi.addIcon(image)
}
}
}
open suspend fun existingIcon(iconName: String): Result> =
runCatching {
evaluationsApi.existingIconAsync(iconName)
}
open suspend fun deleteIcon(id: Int): Result =
runCatching {
evaluationsApi.deleteIcon(id)
Unit
}
open suspend fun fetchEvaluation(appPackageName: String, microG: Int, rooted: Int): Result =
runCatching {
val answer = evaluationsApi.getSingleEvaluationAsync(
appPackageName,
microG,
rooted
)
answer.data.firstOrNull()?.attributes
}
private fun fromDrawableToByArray(drawable: AdaptiveIconDrawable): ByteArray {
val bitmap = drawable.toBitmap()
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, COMPRESSION_QUALITY, stream)
return stream.toByteArray()
}
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/di/DataModule.kt
================================================
package com.klee.sapio.data.di
import com.klee.sapio.data.fdroid.CachedFdroidAvailabilityChecker
import com.klee.sapio.data.repository.DeviceAppCacheRepositoryImpl
import com.klee.sapio.data.repository.InstalledApplicationsRepository
import com.klee.sapio.data.system.DeviceConfiguration
import com.klee.sapio.data.system.Settings
import com.klee.sapio.domain.AppSettings
import com.klee.sapio.domain.DeviceAppCacheRepository
import com.klee.sapio.domain.DeviceInfo
import com.klee.sapio.domain.FdroidAvailabilityChecker
import com.klee.sapio.domain.InstalledApplicationsDataSource
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
@Singleton
abstract fun bindFdroidAvailabilityChecker(
impl: CachedFdroidAvailabilityChecker
): FdroidAvailabilityChecker
@Binds
@Singleton
abstract fun bindDeviceInfo(
impl: DeviceConfiguration
): DeviceInfo
@Binds
@Singleton
abstract fun bindInstalledApplicationsDataSource(
impl: InstalledApplicationsRepository
): InstalledApplicationsDataSource
@Binds
@Singleton
abstract fun bindAppSettings(
impl: Settings
): AppSettings
@Binds
@Singleton
abstract fun bindDeviceAppCacheRepository(
impl: DeviceAppCacheRepositoryImpl
): DeviceAppCacheRepository
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/dto/Evaluation.kt
================================================
package com.klee.sapio.data.dto
import com.fasterxml.jackson.annotation.JsonProperty
import java.util.Date
data class Evaluation(
@JsonProperty("name") val name: String,
@JsonProperty("packageName") val packageName: String,
@JsonProperty("icon") val icon: Icon?,
@JsonProperty("rating") val rating: Int,
@JsonProperty("microg") val microg: Int,
@JsonProperty("rooted") val secure: Int,
@JsonProperty("updatedAt") val updatedAt: Date?,
@JsonProperty("createdAt") val createdAt: Date?,
@JsonProperty("publishedAt") val publishedAt: Date?,
@JsonProperty("versionName") val versionName: String?
)
================================================
FILE: data/src/main/java/com/klee/sapio/data/dto/IconDtos.kt
================================================
package com.klee.sapio.data.dto
import com.fasterxml.jackson.annotation.JsonProperty
import java.util.Date
data class RemoteImage(
@JsonProperty("name") val name: String,
@JsonProperty("alternativeText") val alternativeText: String?,
@JsonProperty("caption") val caption: String?,
@JsonProperty("width") val width: Int,
@JsonProperty("height") val height: Int,
@JsonProperty("formats") val formats: RemoteImageFormats?,
@JsonProperty("hash") val hash: String,
@JsonProperty("ext") val ext: String,
@JsonProperty("mime") val mime: String,
@JsonProperty("size") val size: Int,
@JsonProperty("url") val url: String,
@JsonProperty("previewUrl") val previewUrl: String?,
@JsonProperty("provider") val provider: String?,
@JsonProperty("provider_metadata") val provider_metadata: String?,
@JsonProperty("createdAt") val createdAt: Date,
@JsonProperty("updatedAt") val updatedAt: Date
)
data class RemoteImageFormats(
@JsonProperty("thumbnail") val thumbnail: Image,
@JsonProperty("large") val large: Image?,
@JsonProperty("medium") val medium: Image?,
@JsonProperty("small") val small: Image?
)
data class Image(
@JsonProperty("name") val name: String,
@JsonProperty("hash") val hash: String,
@JsonProperty("ext") val ext: String,
@JsonProperty("mime") val mime: String,
@JsonProperty("path") val path: String?,
@JsonProperty("width") val width: Int,
@JsonProperty("height") val height: Int,
@JsonProperty("size") val size: Int,
@JsonProperty("url") val url: String
)
data class IconAnswer(
@JsonProperty("id") val id: Int,
@JsonProperty("name") val name: String,
@JsonProperty("alternativeText") val alternativeText: String?,
@JsonProperty("caption") val caption: String?,
@JsonProperty("width") val width: Int,
@JsonProperty("height") val height: Int,
@JsonProperty("formats") val formats: RemoteImageFormats?,
@JsonProperty("hash") val hash: String,
@JsonProperty("ext") val ext: String,
@JsonProperty("mime") val mime: String,
@JsonProperty("size") val size: Int,
@JsonProperty("url") val url: String,
@JsonProperty("previewUrl") val previewUrl: String?,
@JsonProperty("provider") val provider: String?,
@JsonProperty("provider_metadata") val provider_metadata: String?,
@JsonProperty("createdAt") val createdAt: Date,
@JsonProperty("updatedAt") val updatedAt: Date
)
================================================
FILE: data/src/main/java/com/klee/sapio/data/dto/StrapiDtos.kt
================================================
package com.klee.sapio.data.dto
import com.fasterxml.jackson.annotation.JsonProperty
data class StrapiAnswer(
@JsonProperty("data") val data: ArrayList,
@JsonProperty("meta") val meta: StrapiMeta
)
data class StrapiMeta(
@JsonProperty("pagination") val pagination: StrapiPagination?
)
data class StrapiPagination(
@JsonProperty("page") val page: Int,
@JsonProperty("pageSize") val pageSize: Int,
@JsonProperty("pageCount") val pageCount: Int,
@JsonProperty("total") val total: Int
)
data class StrapiImageElement(
@JsonProperty("id") val id: Int,
@JsonProperty("attributes") val attributes: RemoteImage
)
data class StrapiElement(
@JsonProperty("id") val id: Int,
@JsonProperty("attributes") val attributes: Evaluation
)
data class Icon(
@JsonProperty("data") val data: StrapiImageElement?
)
================================================
FILE: data/src/main/java/com/klee/sapio/data/dto/UploadDtos.kt
================================================
package com.klee.sapio.data.dto
import com.fasterxml.jackson.annotation.JsonProperty
data class UploadEvaluation(
@JsonProperty("name") val name: String,
@JsonProperty("packageName") val packageName: String,
@JsonProperty("icon") var icon: Int?,
@JsonProperty("rating") val rating: Int,
@JsonProperty("microg") val microg: Int,
@JsonProperty("rooted") val rooted: Int
)
data class UploadAnswer(
@JsonProperty("data") val data: StrapiElement,
@JsonProperty("meta") val meta: StrapiMeta?
)
data class UploadEvaluationHeader(
@JsonProperty("data") var data: UploadEvaluation
)
================================================
FILE: data/src/main/java/com/klee/sapio/data/fdroid/CachedFdroidAvailabilityChecker.kt
================================================
package com.klee.sapio.data.fdroid
import com.klee.sapio.domain.FdroidAvailabilityChecker
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
class CachedFdroidAvailabilityChecker @Inject constructor(
private val delegate: OkHttpFdroidAvailabilityChecker
) : FdroidAvailabilityChecker {
private data class CacheEntry(val isAvailable: Boolean, val cachedAt: Long)
private val cache = ConcurrentHashMap()
override suspend fun isAvailable(packageName: String): Boolean? {
val entry = cache[packageName]
if (entry != null && System.currentTimeMillis() - entry.cachedAt < CACHE_VALIDITY_MS) {
return entry.isAvailable
}
val result = delegate.isAvailable(packageName)
if (result != null) {
cache[packageName] = CacheEntry(result, System.currentTimeMillis())
}
return result
}
companion object {
private const val CACHE_VALIDITY_MS = 86_400_000L // 24 hours
}
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/fdroid/OkHttpFdroidAvailabilityChecker.kt
================================================
package com.klee.sapio.data.fdroid
import android.util.Log
import com.klee.sapio.domain.FdroidAvailabilityChecker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import javax.inject.Inject
class OkHttpFdroidAvailabilityChecker @Inject constructor() : FdroidAvailabilityChecker {
private val client = OkHttpClient()
override suspend fun isAvailable(packageName: String): Boolean? {
val request = Request.Builder()
.url("https://f-droid.org/api/v1/packages/$packageName")
.build()
return try {
withContext(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
response.code == HTTP_200_SUCCESS
}
}
} catch (e: IOException) {
Log.e("OkHttpFdroidChecker", "Failed to reach F-Droid for $packageName", e)
null
}
}
companion object {
private const val HTTP_200_SUCCESS = 200
}
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/local/AppDatabase.kt
================================================
package com.klee.sapio.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
entities = [EvaluationEntity::class, IconEntity::class, DeviceAppEntity::class],
version = 5,
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun evaluationDao(): EvaluationDao
abstract fun iconDao(): IconDao
abstract fun deviceAppDao(): DeviceAppDao
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/local/Converters.kt
================================================
package com.klee.sapio.data.local
import androidx.room.TypeConverter
import java.util.Date
class Converters {
@TypeConverter
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
}
@TypeConverter
fun dateToTimestamp(date: Date?): Long? {
return date?.time
}
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/local/DatabaseModule.kt
================================================
package com.klee.sapio.data.local
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"sapio.db"
).fallbackToDestructiveMigration()
.build()
}
@Provides
fun provideEvaluationDao(database: AppDatabase): EvaluationDao = database.evaluationDao()
@Provides
fun provideIconDao(database: AppDatabase): IconDao = database.iconDao()
@Provides
fun provideDeviceAppDao(database: AppDatabase): DeviceAppDao = database.deviceAppDao()
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/local/DeviceAppDao.kt
================================================
package com.klee.sapio.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface DeviceAppDao {
@Query("SELECT * FROM DeviceAppEntity")
suspend fun getAll(): List
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(items: List)
@Query("DELETE FROM DeviceAppEntity")
suspend fun deleteAll()
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/local/DeviceAppEntity.kt
================================================
package com.klee.sapio.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class DeviceAppEntity(
@PrimaryKey val packageName: String,
val rating: Int?,
val cachedAt: Long
)
================================================
FILE: data/src/main/java/com/klee/sapio/data/local/EvaluationDao.kt
================================================
package com.klee.sapio.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface EvaluationDao {
@Query(
"""
SELECT * FROM EvaluationEntity
ORDER BY updatedAt DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun listLatestEvaluations(
limit: Int,
offset: Int
): List
@Query(
"""
SELECT * FROM EvaluationEntity
WHERE (name LIKE :pattern OR packageName LIKE :pattern)
ORDER BY name
"""
)
suspend fun searchEvaluations(pattern: String): List
@Query(
"""
SELECT * FROM EvaluationEntity
WHERE packageName = :packageName AND microg = :microg AND secure = :secure
LIMIT 1
"""
)
suspend fun getEvaluation(
packageName: String,
microg: Int,
secure: Int
): EvaluationEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(items: List)
}
@Dao
interface IconDao {
@Query(
"""
SELECT * FROM IconEntity
WHERE name = :iconName
ORDER BY id DESC
"""
)
suspend fun findByName(iconName: String): List
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(items: List)
@Query("DELETE FROM IconEntity WHERE id = :id")
suspend fun deleteById(id: Int)
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/local/EvaluationEntity.kt
================================================
package com.klee.sapio.data.local
import androidx.room.Entity
import java.util.Date
@Entity(primaryKeys = ["packageName", "microg", "secure"])
data class EvaluationEntity(
val name: String,
val packageName: String,
val iconUrl: String?,
val rating: Int,
val microg: Int,
val secure: Int,
val updatedAt: Date?,
val createdAt: Date?,
val publishedAt: Date?,
val versionName: String?,
val cachedAt: Long
)
@Entity(primaryKeys = ["id"])
data class IconEntity(
val id: Int,
val name: String,
val url: String,
val cachedAt: Long
)
================================================
FILE: data/src/main/java/com/klee/sapio/data/repository/DeviceAppCacheRepositoryImpl.kt
================================================
package com.klee.sapio.data.repository
import com.klee.sapio.data.local.DeviceAppDao
import com.klee.sapio.data.local.DeviceAppEntity
import com.klee.sapio.domain.DeviceAppCacheRepository
import com.klee.sapio.domain.model.CachedDeviceApp
import javax.inject.Inject
class DeviceAppCacheRepositoryImpl @Inject constructor(
private val deviceAppDao: DeviceAppDao
) : DeviceAppCacheRepository {
override suspend fun getAll(): List {
return deviceAppDao.getAll().map { it.toDomain() }
}
override suspend fun replaceAll(items: List) {
deviceAppDao.deleteAll()
deviceAppDao.upsertAll(items.map { it.toEntity() })
}
}
private fun DeviceAppEntity.toDomain(): CachedDeviceApp = CachedDeviceApp(
packageName = packageName,
rating = rating,
cachedAt = cachedAt
)
private fun CachedDeviceApp.toEntity(): DeviceAppEntity = DeviceAppEntity(
packageName = packageName,
rating = rating,
cachedAt = cachedAt
)
================================================
FILE: data/src/main/java/com/klee/sapio/data/repository/EvaluationRepositoryImpl.kt
================================================
package com.klee.sapio.data.repository
import com.klee.sapio.data.api.EvaluationService
import com.klee.sapio.data.dto.IconAnswer
import com.klee.sapio.data.dto.StrapiElement
import com.klee.sapio.data.dto.UploadEvaluation as DtoUploadEvaluation
import com.klee.sapio.data.dto.UploadEvaluationHeader
import com.klee.sapio.data.local.EvaluationDao
import com.klee.sapio.data.local.EvaluationEntity
import com.klee.sapio.data.local.IconDao
import com.klee.sapio.data.local.IconEntity
import com.klee.sapio.domain.EvaluationRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import javax.inject.Inject
import com.klee.sapio.data.dto.Evaluation as DtoEvaluation
import com.klee.sapio.domain.model.Evaluation as DomainEvaluation
import com.klee.sapio.domain.model.EvaluationRecord as DomainEvaluationRecord
import com.klee.sapio.domain.model.Icon as DomainIcon
import com.klee.sapio.domain.model.UploadEvaluation as DomainUploadEvaluation
class EvaluationRepositoryImpl @Inject constructor(
private val retrofitService: EvaluationService,
private val evaluationDao: EvaluationDao,
private val iconDao: IconDao
) :
EvaluationRepository {
companion object {
private const val PAGE_SIZE = 20
}
override suspend fun listLatestEvaluations(pageNumber: Int): Result> {
val offset = (pageNumber - 1).coerceAtLeast(0) * PAGE_SIZE
val remote = retrofitService.listLatestEvaluations(pageNumber)
if (remote.isSuccess) {
val evaluations = remote.getOrThrow()
val now = System.currentTimeMillis()
evaluationDao.upsertAll(evaluations.map { it.toEntity(now) })
return Result.success(evaluations.enrichWithIcons())
}
val cached = evaluationDao.listLatestEvaluations(PAGE_SIZE, offset)
.map { it.toDomain() }
return if (cached.isNotEmpty()) {
Result.success(cached)
} else {
Result.failure(remote.exceptionOrNull() ?: IllegalStateException("Failed to load evaluations"))
}
}
override suspend fun searchEvaluations(pattern: String): Result> {
val remote = retrofitService.searchEvaluation(pattern)
if (remote.isSuccess) {
val evaluations = remote.getOrThrow()
val now = System.currentTimeMillis()
evaluationDao.upsertAll(evaluations.map { it.toEntity(now) })
return Result.success(evaluations.enrichWithIcons())
}
val cached = evaluationDao.searchEvaluations("%$pattern%")
.map { it.toDomain() }
return if (cached.isNotEmpty()) {
Result.success(cached)
} else {
Result.failure(remote.exceptionOrNull() ?: IllegalStateException("Failed to search evaluations"))
}
}
override suspend fun addEvaluation(evaluation: DomainUploadEvaluation): Result {
val header = UploadEvaluationHeader(evaluation.toData())
return retrofitService.addEvaluation(header)
}
override suspend fun updateEvaluation(evaluation: DomainUploadEvaluation, id: Int): Result {
val header = UploadEvaluationHeader(evaluation.toData())
return retrofitService.updateEvaluation(header, id)
}
override suspend fun fetchEvaluation(
appPackageName: String,
gmsType: Int,
userType: Int
): Result {
return fetchEvaluationWithFallback(appPackageName, gmsType, userType)
}
override suspend fun existingEvaluations(packageName: String): Result> {
return retrofitService.existingEvaluations(packageName)
.map { evaluations -> evaluations.map { it.toDomain() } }
}
override suspend fun uploadIcon(packageName: String): Result> {
val remote = retrofitService.uploadIcon(packageName)
if (remote.isSuccess) {
val icons = remote.getOrThrow()
val now = System.currentTimeMillis()
iconDao.upsertAll(icons.map { it.toEntity(now) })
return Result.success(icons.map { it.toDomain() })
}
val cached = iconDao.findByName("$packageName.png")
.map { it.toDomain() }
return if (cached.isNotEmpty()) {
Result.success(cached)
} else {
Result.failure(remote.exceptionOrNull() ?: IllegalStateException("Failed to upload icon"))
}
}
override suspend fun existingIcon(iconName: String): Result> {
val remote = retrofitService.existingIcon(iconName)
if (remote.isSuccess) {
val icons = remote.getOrThrow()
val now = System.currentTimeMillis()
iconDao.upsertAll(icons.map { it.toEntity(now) })
return Result.success(icons.map { it.toDomain() })
}
val cached = iconDao.findByName(iconName)
.map { it.toDomain() }
return if (cached.isNotEmpty()) {
Result.success(cached)
} else {
Result.failure(remote.exceptionOrNull() ?: IllegalStateException("Failed to load icon"))
}
}
override suspend fun deleteIcon(id: Int): Result {
val remote = retrofitService.deleteIcon(id)
if (remote.isSuccess) {
iconDao.deleteById(id)
}
return remote
}
private suspend fun fetchEvaluationWithFallback(
packageName: String,
microg: Int,
secure: Int
): Result {
val remote = retrofitService.fetchEvaluation(packageName, microg, secure)
if (remote.isSuccess) {
val evaluation = remote.getOrNull()
if (evaluation != null) {
val now = System.currentTimeMillis()
evaluationDao.upsertAll(listOf(evaluation.toEntity(now)))
}
return Result.success(evaluation?.toDomain())
}
val cached = evaluationDao.getEvaluation(packageName, microg, secure)
?.toDomain()
return if (cached != null) {
Result.success(cached)
} else {
Result.failure(remote.exceptionOrNull() ?: IllegalStateException("Failed to load evaluation"))
}
}
private suspend fun List.enrichWithIcons(): List =
coroutineScope {
map { evaluation ->
async {
val domain = evaluation.toDomain()
if (domain.iconUrl == null) {
val fallback = retrofitService.existingIcon("${evaluation.packageName}.png")
domain.copy(iconUrl = fallback.getOrNull()?.firstOrNull()?.url.toAbsoluteUrl())
} else {
domain
}
}
}.awaitAll()
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class EvaluationRepositoryModule {
@Binds
abstract fun bindEvaluationRepository(
evaluationRepositoryImpl: EvaluationRepositoryImpl
): EvaluationRepository
}
private fun DtoEvaluation.toDomain(): DomainEvaluation = DomainEvaluation(
name = name,
packageName = packageName,
iconUrl = icon?.data?.attributes?.url.toAbsoluteUrl(),
rating = rating,
microg = microg,
secure = secure,
updatedAt = updatedAt,
createdAt = createdAt,
publishedAt = publishedAt,
versionName = versionName
)
private fun DtoEvaluation.toEntity(cachedAt: Long): EvaluationEntity = EvaluationEntity(
name = name,
packageName = packageName,
iconUrl = icon?.data?.attributes?.url,
rating = rating,
microg = microg,
secure = secure,
updatedAt = updatedAt,
createdAt = createdAt,
publishedAt = publishedAt,
versionName = versionName,
cachedAt = cachedAt
)
private fun EvaluationEntity.toDomain(): DomainEvaluation = DomainEvaluation(
name = name,
packageName = packageName,
iconUrl = iconUrl.toAbsoluteUrl(),
rating = rating,
microg = microg,
secure = secure,
updatedAt = updatedAt,
createdAt = createdAt,
publishedAt = publishedAt,
versionName = versionName
)
private fun StrapiElement.toDomain(): DomainEvaluationRecord = DomainEvaluationRecord(
id = id,
evaluation = attributes.toDomain()
)
private fun IconAnswer.toDomain(): DomainIcon = DomainIcon(
id = id,
name = name,
url = url.toAbsoluteUrl().orEmpty()
)
private fun IconAnswer.toEntity(cachedAt: Long): IconEntity = IconEntity(
id = id,
name = name,
url = url,
cachedAt = cachedAt
)
private fun IconEntity.toDomain(): DomainIcon = DomainIcon(
id = id,
name = name,
url = url.toAbsoluteUrl().orEmpty()
)
private fun DomainUploadEvaluation.toData(): DtoUploadEvaluation = DtoUploadEvaluation(
name = name,
packageName = packageName,
icon = icon,
rating = rating,
microg = microg,
rooted = rooted
)
private fun String?.toAbsoluteUrl(): String? {
val value = this ?: return null
return if (value.startsWith("http://") || value.startsWith("https://")) {
value
} else {
"${EvaluationService.BASE_URL}$value"
}
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/repository/InstalledApplicationsRepository.kt
================================================
package com.klee.sapio.data.repository
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.graphics.drawable.AdaptiveIconDrawable
import android.os.Build
import androidx.annotation.VisibleForTesting
import com.klee.sapio.domain.InstalledApplicationsDataSource
import com.klee.sapio.domain.model.InstalledApplication
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
open class InstalledApplicationsRepository @Inject constructor(
@ApplicationContext private val context: Context
) : InstalledApplicationsDataSource {
override fun listInstalledApplications(): List {
val pm = context.packageManager
val intent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_LAUNCHER)
}
val resolveInfoList = try {
pm.queryIntentActivities(intent, 0)
} catch (e: RuntimeException) {
return emptyList()
}
val results: MutableList = arrayListOf()
for (resolveInfo in resolveInfoList) {
val appInfo = resolveInfo.activityInfo.applicationInfo
if (isSystemApp(appInfo) || isGmsRelated(appInfo)) continue
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !hasAdaptiveIcon(context, appInfo)) continue
val label = try {
pm.getApplicationLabel(appInfo).toString()
} catch (e: RuntimeException) {
continue
}
results.add(InstalledApplication(label, appInfo.packageName))
}
return results.sortedBy { app -> app.name.lowercase() }
}
override fun getInstalledApplication(packageName: String): InstalledApplication? {
val appList = listInstalledApplications()
for (app in appList) {
if (app.packageName == packageName) {
return app
}
}
return null
}
@VisibleForTesting
fun isSystemApp(info: ApplicationInfo): Boolean {
return info.flags and ApplicationInfo.FLAG_SYSTEM != 0
}
@VisibleForTesting
fun isGmsRelated(info: ApplicationInfo): Boolean {
val pkgName = info.packageName ?: return false
return pkgName.endsWith(".gms") || pkgName == "com.android.vending"
}
@VisibleForTesting
fun hasAdaptiveIcon(context: Context, info: ApplicationInfo): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return try {
val icon = info.loadUnbadgedIcon(context.packageManager) ?: return false
icon is AdaptiveIconDrawable
} catch (e: RuntimeException) {
false
}
}
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/system/DeviceConfiguration.kt
================================================
package com.klee.sapio.data.system
import android.content.Context
import android.content.pm.PackageManager
import com.klee.sapio.domain.DeviceInfo
import com.klee.sapio.domain.model.GmsType
import com.klee.sapio.domain.model.UserType
import com.scottyab.rootbeer.RootBeer
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
open class DeviceConfiguration @Inject constructor(
@ApplicationContext private val mContext: Context
) : DeviceInfo {
companion object {
const val GMS_SERVICES_PACKAGE_NAME = "com.google.android.gms"
}
private var packageManager: PackageManager = mContext.packageManager
override fun getGmsType(): Int = cachedGmsType
private val cachedGmsType: Int by lazy { computeGmsType() }
private fun computeGmsType(): Int {
val apps = try {
packageManager.getInstalledApplications(0)
} catch (e: RuntimeException) {
return GmsType.BARE_AOSP
}
val gmsApp = apps.firstOrNull { it.packageName == GMS_SERVICES_PACKAGE_NAME }
?: return GmsType.BARE_AOSP
return try {
if (packageManager.getApplicationLabel(gmsApp).toString().contains("Google", true)) {
GmsType.GOOGLE_PLAY_SERVICES
} else {
GmsType.MICROG
}
} catch (e: RuntimeException) {
GmsType.BARE_AOSP
}
}
override fun isUnsafe(): Int {
return if (isRooted() && !isBootloaderLocked()) {
UserType.UNSAFE
} else {
UserType.SECURE
}
}
protected open fun isRooted(): Boolean = RootBeer(mContext).isRooted
protected open fun isBootloaderLocked(): Boolean {
val verifiedBootState = SystemPropertyReader().read("ro.boot.verifiedbootstate")
return verifiedBootState == "yellow" || verifiedBootState == "green"
}
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/system/Settings.kt
================================================
package com.klee.sapio.data.system
import android.content.Context
import androidx.preference.PreferenceManager
import com.klee.sapio.domain.AppSettings
import com.klee.sapio.domain.model.UserType
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
open class Settings @Inject constructor(
@ApplicationContext private val mContext: Context
) : AppSettings {
override fun getUnsafeConfigurationLevel(): Int {
return if (isUnsafeConfigurationEnabled()) {
UserType.UNSAFE
} else {
UserType.SECURE
}
}
override fun isUnsafeConfigurationEnabled(): Boolean {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(mContext)
return sharedPreferences.getBoolean("show_root", false)
}
}
================================================
FILE: data/src/main/java/com/klee/sapio/data/system/SystemPropertyReader.kt
================================================
package com.klee.sapio.data.system
import android.annotation.SuppressLint
import android.util.Log
@SuppressLint("PrivateApi")
class SystemPropertyReader {
private val getMethod by lazy {
val clazz = Class.forName("android.os.SystemProperties")
clazz.getMethod("get", String::class.java, String::class.java)
}
fun read(propertyName: String): String {
return try {
val value = getMethod.invoke(null, propertyName, "") as String
value.trim()
} catch (exception: ReflectiveOperationException) {
Log.w(TAG, "Unable to read property $propertyName", exception)
""
} catch (exception: IllegalArgumentException) {
Log.w(TAG, "Invalid arguments supplied for $propertyName", exception)
""
}
}
private companion object {
private const val TAG = "SystemPropertyReader"
}
}
================================================
FILE: data/src/test/java/com/klee/sapio/data/CachedFdroidAvailabilityCheckerTest.kt
================================================
package com.klee.sapio.data
import com.klee.sapio.data.fdroid.CachedFdroidAvailabilityChecker
import com.klee.sapio.data.fdroid.OkHttpFdroidAvailabilityChecker
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
class CachedFdroidAvailabilityCheckerTest {
@Mock
private lateinit var delegate: OkHttpFdroidAvailabilityChecker
@Before
fun setUp() {
MockitoAnnotations.openMocks(this)
}
@Test
fun `returns true when delegate reports available`() = runTest {
`when`(delegate.isAvailable("com.test.app")).thenReturn(true)
val checker = CachedFdroidAvailabilityChecker(delegate)
assertTrue(checker.isAvailable("com.test.app")!!)
}
@Test
fun `returns false when delegate reports unavailable`() = runTest {
`when`(delegate.isAvailable("com.test.app")).thenReturn(false)
val checker = CachedFdroidAvailabilityChecker(delegate)
assertFalse(checker.isAvailable("com.test.app")!!)
}
@Test
fun `returns null when delegate reports network error`() = runTest {
`when`(delegate.isAvailable("com.test.app")).thenReturn(null)
val checker = CachedFdroidAvailabilityChecker(delegate)
assertNull(checker.isAvailable("com.test.app"))
}
@Test
fun `does not cache network errors - retries delegate on next call`() = runTest {
`when`(delegate.isAvailable("com.test.app"))
.thenReturn(null)
.thenReturn(true)
val checker = CachedFdroidAvailabilityChecker(delegate)
checker.isAvailable("com.test.app")
val second = checker.isAvailable("com.test.app")
verify(delegate, times(2)).isAvailable("com.test.app")
assertTrue(second!!)
}
@Test
fun `caches true results - does not call delegate again`() = runTest {
`when`(delegate.isAvailable("com.test.app")).thenReturn(true)
val checker = CachedFdroidAvailabilityChecker(delegate)
checker.isAvailable("com.test.app")
checker.isAvailable("com.test.app")
verify(delegate, times(1)).isAvailable("com.test.app")
}
@Test
fun `caches false results - does not call delegate again`() = runTest {
`when`(delegate.isAvailable("com.test.app")).thenReturn(false)
val checker = CachedFdroidAvailabilityChecker(delegate)
checker.isAvailable("com.test.app")
checker.isAvailable("com.test.app")
verify(delegate, times(1)).isAvailable("com.test.app")
}
}
================================================
FILE: data/src/test/java/com/klee/sapio/data/ConvertersTest.kt
================================================
package com.klee.sapio.data
import com.klee.sapio.data.local.Converters
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.util.Date
class ConvertersTest {
private val converters = Converters()
@Test
fun `fromTimestamp returns null when value is null`() {
assertNull(converters.fromTimestamp(null))
}
@Test
fun `fromTimestamp returns correct Date for given timestamp`() {
val timestamp = 1700000000000L
val result = converters.fromTimestamp(timestamp)
assertEquals(Date(timestamp), result)
}
@Test
fun `fromTimestamp returns epoch date for zero`() {
val result = converters.fromTimestamp(0L)
assertEquals(Date(0), result)
}
@Test
fun `dateToTimestamp returns null when date is null`() {
assertNull(converters.dateToTimestamp(null))
}
@Test
fun `dateToTimestamp returns correct timestamp for given date`() {
val timestamp = 1700000000000L
val date = Date(timestamp)
assertEquals(timestamp, converters.dateToTimestamp(date))
}
@Test
fun `dateToTimestamp returns zero for epoch date`() {
assertEquals(0L, converters.dateToTimestamp(Date(0)))
}
@Test
fun `fromTimestamp and dateToTimestamp are inverse operations`() {
val timestamp = 1700000000000L
val date = converters.fromTimestamp(timestamp)
assertEquals(timestamp, converters.dateToTimestamp(date))
}
}
================================================
FILE: data/src/test/java/com/klee/sapio/data/EvaluationRepositoryImplTest.kt
================================================
package com.klee.sapio.data
import android.content.Context
import android.os.Build
import org.robolectric.RuntimeEnvironment
import com.klee.sapio.data.api.EvaluationService
import com.klee.sapio.data.dto.Evaluation as DtoEvaluation
import com.klee.sapio.data.dto.IconAnswer
import com.klee.sapio.data.dto.StrapiElement
import com.klee.sapio.data.dto.UploadEvaluationHeader
import com.klee.sapio.data.local.EvaluationDao
import com.klee.sapio.data.local.EvaluationEntity
import com.klee.sapio.data.local.IconDao
import com.klee.sapio.data.local.IconEntity
import com.klee.sapio.data.repository.EvaluationRepositoryImpl
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Date
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.M])
class EvaluationRepositoryImplTest {
private lateinit var service: FakeEvaluationService
private lateinit var evaluationDao: FakeEvaluationDao
private lateinit var iconDao: FakeIconDao
private lateinit var repository: EvaluationRepositoryImpl
@Before
fun setUp() {
val context = RuntimeEnvironment.getApplication() as Context
service = FakeEvaluationService(context)
evaluationDao = FakeEvaluationDao()
iconDao = FakeIconDao()
repository = EvaluationRepositoryImpl(service, evaluationDao, iconDao)
}
// region listLatestEvaluations
@Test
fun `listLatestEvaluations returns remote data on success`() = runTest {
val dto = dtoEvaluation(name = "App", packageName = "com.app", iconUrl = "http://icon.png")
service.listLatestResult = Result.success(listOf(dto))
val result = repository.listLatestEvaluations(1)
assertTrue(result.isSuccess)
assertEquals(1, result.getOrNull()?.size)
assertEquals("App", result.getOrNull()?.first()?.name)
}
@Test
fun `listLatestEvaluations caches remote data on success`() = runTest {
val dto = dtoEvaluation(name = "App", packageName = "com.app", iconUrl = "http://icon.png")
service.listLatestResult = Result.success(listOf(dto))
repository.listLatestEvaluations(1)
assertEquals(1, evaluationDao.upsertedItems.size)
}
@Test
fun `listLatestEvaluations returns cache on remote failure when cache is non-empty`() = runTest {
service.listLatestResult = Result.failure(RuntimeException("network error"))
evaluationDao.listResult = listOf(evaluationEntity(name = "Cached App"))
val result = repository.listLatestEvaluations(1)
assertTrue(result.isSuccess)
assertEquals("Cached App", result.getOrNull()?.first()?.name)
}
@Test
fun `listLatestEvaluations returns failure on remote failure when cache is empty`() = runTest {
service.listLatestResult = Result.failure(RuntimeException("network error"))
evaluationDao.listResult = emptyList()
val result = repository.listLatestEvaluations(1)
assertTrue(result.isFailure)
}
@Test
fun `listLatestEvaluations fetches missing icon url via enrichment`() = runTest {
val dto = dtoEvaluation(name = "App", packageName = "com.app", iconUrl = null)
service.listLatestResult = Result.success(listOf(dto))
service.existingIconResult = Result.success(listOf(iconAnswer(url = "http://fallback.png")))
val result = repository.listLatestEvaluations(1)
assertEquals("http://fallback.png", result.getOrNull()?.first()?.iconUrl)
}
// endregion
// region searchEvaluations
@Test
fun `searchEvaluations returns remote data on success`() = runTest {
val dto = dtoEvaluation(name = "Firefox", packageName = "org.mozilla.firefox", iconUrl = "http://icon.png")
service.searchResult = Result.success(listOf(dto))
val result = repository.searchEvaluations("firefox")
assertTrue(result.isSuccess)
assertEquals("Firefox", result.getOrNull()?.first()?.name)
}
@Test
fun `searchEvaluations caches remote results`() = runTest {
service.searchResult = Result.success(listOf(dtoEvaluation(iconUrl = "http://icon.png")))
repository.searchEvaluations("test")
assertEquals(1, evaluationDao.upsertedItems.size)
}
@Test
fun `searchEvaluations returns cache on remote failure when cache is non-empty`() = runTest {
service.searchResult = Result.failure(RuntimeException())
evaluationDao.searchResult = listOf(evaluationEntity(name = "Cached"))
val result = repository.searchEvaluations("cached")
assertTrue(result.isSuccess)
assertEquals("Cached", result.getOrNull()?.first()?.name)
}
@Test
fun `searchEvaluations returns failure on remote failure when cache is empty`() = runTest {
service.searchResult = Result.failure(RuntimeException())
evaluationDao.searchResult = emptyList()
val result = repository.searchEvaluations("nothing")
assertTrue(result.isFailure)
}
// endregion
// region addEvaluation and updateEvaluation
@Test
fun `addEvaluation returns success when service succeeds`() = runTest {
service.addEvalResult = Result.success(Unit)
val result = repository.addEvaluation(domainUploadEvaluation())
assertTrue(result.isSuccess)
}
@Test
fun `addEvaluation returns failure when service fails`() = runTest {
service.addEvalResult = Result.failure(RuntimeException("server error"))
val result = repository.addEvaluation(domainUploadEvaluation())
assertTrue(result.isFailure)
}
@Test
fun `updateEvaluation returns success when service succeeds`() = runTest {
service.updateEvalResult = Result.success(Unit)
val result = repository.updateEvaluation(domainUploadEvaluation(), id = 1)
assertTrue(result.isSuccess)
}
// endregion
// region fetchEvaluation
@Test
fun `fetchEvaluation returns evaluation when remote succeeds`() = runTest {
val dto = dtoEvaluation(name = "App", packageName = "com.app")
service.fetchEvalResult = Result.success(dto)
val result = repository.fetchEvaluation("com.app", gmsType = 1, userType = 3)
assertTrue(result.isSuccess)
assertEquals("App", result.getOrNull()?.name)
}
@Test
fun `fetchEvaluation caches evaluation when remote succeeds`() = runTest {
service.fetchEvalResult = Result.success(dtoEvaluation())
repository.fetchEvaluation("com.app", gmsType = 1, userType = 3)
assertEquals(1, evaluationDao.upsertedItems.size)
}
@Test
fun `fetchEvaluation returns null when remote returns null`() = runTest {
service.fetchEvalResult = Result.success(null)
val result = repository.fetchEvaluation("com.app", gmsType = 1, userType = 3)
assertTrue(result.isSuccess)
assertNull(result.getOrNull())
}
@Test
fun `fetchEvaluation returns cache on remote failure when cached`() = runTest {
service.fetchEvalResult = Result.failure(RuntimeException())
evaluationDao.getEvaluationResult = evaluationEntity(name = "Cached App")
val result = repository.fetchEvaluation("com.app", gmsType = 1, userType = 3)
assertTrue(result.isSuccess)
assertEquals("Cached App", result.getOrNull()?.name)
}
@Test
fun `fetchEvaluation returns failure when remote fails and cache is empty`() = runTest {
service.fetchEvalResult = Result.failure(RuntimeException())
evaluationDao.getEvaluationResult = null
val result = repository.fetchEvaluation("com.app", gmsType = 1, userType = 3)
assertTrue(result.isFailure)
}
// endregion
// region uploadIcon
@Test
fun `uploadIcon returns icons from remote on success`() = runTest {
service.uploadIconResult = Result.success(listOf(iconAnswer(id = 5, url = "http://icon.png")))
val result = repository.uploadIcon("com.test.app")
assertTrue(result.isSuccess)
assertEquals(5, result.getOrNull()?.first()?.id)
}
@Test
fun `uploadIcon caches icons on remote success`() = runTest {
service.uploadIconResult = Result.success(listOf(iconAnswer()))
repository.uploadIcon("com.test.app")
assertEquals(1, iconDao.upsertedItems.size)
}
@Test
fun `uploadIcon returns cached icons on remote failure`() = runTest {
service.uploadIconResult = Result.failure(RuntimeException())
iconDao.findByNameResult = listOf(iconEntity(id = 3, url = "http://cached.png"))
val result = repository.uploadIcon("com.test.app")
assertTrue(result.isSuccess)
assertEquals(3, result.getOrNull()?.first()?.id)
}
@Test
fun `uploadIcon returns failure when remote fails and cache is empty`() = runTest {
service.uploadIconResult = Result.failure(RuntimeException())
iconDao.findByNameResult = emptyList()
val result = repository.uploadIcon("com.test.app")
assertTrue(result.isFailure)
}
// endregion
// region existingIcon
@Test
fun `existingIcon returns icons from remote on success`() = runTest {
service.existingIconResult = Result.success(listOf(iconAnswer(url = "http://icon.png")))
val result = repository.existingIcon("com.test.app.png")
assertTrue(result.isSuccess)
assertEquals("http://icon.png", result.getOrNull()?.first()?.url)
}
@Test
fun `existingIcon caches icons on remote success`() = runTest {
service.existingIconResult = Result.success(listOf(iconAnswer()))
repository.existingIcon("com.test.app.png")
assertEquals(1, iconDao.upsertedItems.size)
}
@Test
fun `existingIcon returns cache on remote failure`() = runTest {
service.existingIconResult = Result.failure(RuntimeException())
iconDao.findByNameResult = listOf(iconEntity(url = "http://cached.png"))
val result = repository.existingIcon("com.test.app.png")
assertTrue(result.isSuccess)
assertEquals("http://cached.png", result.getOrNull()?.first()?.url)
}
@Test
fun `existingIcon returns failure when remote fails and cache is empty`() = runTest {
service.existingIconResult = Result.failure(RuntimeException())
iconDao.findByNameResult = emptyList()
val result = repository.existingIcon("com.test.app.png")
assertTrue(result.isFailure)
}
// endregion
// region deleteIcon
@Test
fun `deleteIcon removes from local dao on remote success`() = runTest {
service.deleteIconResult = Result.success(Unit)
repository.deleteIcon(id = 10)
assertTrue(iconDao.deletedIds.contains(10))
}
@Test
fun `deleteIcon does not remove from local dao on remote failure`() = runTest {
service.deleteIconResult = Result.failure(RuntimeException())
repository.deleteIcon(id = 10)
assertTrue(iconDao.deletedIds.isEmpty())
}
@Test
fun `deleteIcon returns failure when remote fails`() = runTest {
service.deleteIconResult = Result.failure(RuntimeException("error"))
val result = repository.deleteIcon(id = 10)
assertTrue(result.isFailure)
}
// endregion
// region helpers
private fun dtoEvaluation(
name: String = "Test App",
packageName: String = "com.test.app",
iconUrl: String? = null,
rating: Int = 1,
microg: Int = 1,
secure: Int = 3
): DtoEvaluation {
val icon = iconUrl?.let {
val imageData = com.klee.sapio.data.dto.RemoteImage(
name = "test.png", alternativeText = null, caption = null,
width = 100, height = 100, formats = null,
hash = "hash", ext = ".png", mime = "image/png",
size = 100, url = it, previewUrl = null,
provider = null, provider_metadata = null,
createdAt = Date(), updatedAt = Date()
)
com.klee.sapio.data.dto.Icon(com.klee.sapio.data.dto.StrapiImageElement(1, imageData))
}
return DtoEvaluation(
name = name, packageName = packageName, icon = icon,
rating = rating, microg = microg, secure = secure,
updatedAt = null, createdAt = null, publishedAt = null, versionName = null
)
}
private fun iconAnswer(
id: Int = 1,
name: String = "com.test.app.png",
url: String = "http://icon/test.png"
) = IconAnswer(
id = id, name = name, url = url,
alternativeText = null, caption = null,
width = 100, height = 100, formats = null,
hash = "hash", ext = ".png", mime = "image/png",
size = 100, previewUrl = null, provider = null,
provider_metadata = null, createdAt = Date(), updatedAt = Date()
)
private fun evaluationEntity(
name: String = "Test App",
packageName: String = "com.test.app",
microg: Int = 1,
secure: Int = 3
) = EvaluationEntity(
name = name, packageName = packageName, iconUrl = null,
rating = 1, microg = microg, secure = secure,
updatedAt = null, createdAt = null, publishedAt = null,
versionName = null, cachedAt = 0L
)
private fun iconEntity(
id: Int = 1,
name: String = "com.test.app.png",
url: String = "http://icon/test.png"
) = IconEntity(id = id, name = name, url = url, cachedAt = 0L)
private fun domainUploadEvaluation() = com.klee.sapio.domain.model.UploadEvaluation(
name = "Test App", packageName = "com.test.app", icon = 1,
rating = 1, microg = 1, rooted = 3
)
// endregion
// region fakes
private class FakeEvaluationService(context: Context) : EvaluationService(context) {
var listLatestResult: Result> = Result.success(emptyList())
var searchResult: Result> = Result.success(emptyList())
var addEvalResult: Result = Result.success(Unit)
var updateEvalResult: Result = Result.success(Unit)
var fetchEvalResult: Result = Result.success(null)
var uploadIconResult: Result> = Result.success(emptyList())
var existingIconResult: Result> = Result.success(emptyList())
var deleteIconResult: Result = Result.success(Unit)
var existingEvaluationsResult: Result> = Result.success(emptyList())
override suspend fun listLatestEvaluations(pageNumber: Int) = listLatestResult
override suspend fun searchEvaluation(pattern: String) = searchResult
override suspend fun addEvaluation(app: UploadEvaluationHeader) = addEvalResult
override suspend fun updateEvaluation(app: UploadEvaluationHeader, id: Int) = updateEvalResult
override suspend fun fetchEvaluation(appPackageName: String, microG: Int, rooted: Int) = fetchEvalResult
override suspend fun uploadIcon(packageName: String) = uploadIconResult
override suspend fun existingIcon(iconName: String) = existingIconResult
override suspend fun deleteIcon(id: Int) = deleteIconResult
override suspend fun existingEvaluations(packageName: String) = existingEvaluationsResult
}
private class FakeEvaluationDao : EvaluationDao {
var listResult: List = emptyList()
var searchResult: List = emptyList()
var getEvaluationResult: EvaluationEntity? = null
val upsertedItems = mutableListOf()
override suspend fun listLatestEvaluations(limit: Int, offset: Int) = listResult
override suspend fun searchEvaluations(pattern: String) = searchResult
override suspend fun getEvaluation(packageName: String, microg: Int, secure: Int) = getEvaluationResult
override suspend fun upsertAll(items: List) { upsertedItems.addAll(items) }
}
private class FakeIconDao : IconDao {
var findByNameResult: List = emptyList()
val upsertedItems = mutableListOf()
val deletedIds = mutableListOf()
override suspend fun findByName(iconName: String) = findByNameResult
override suspend fun upsertAll(items: List) { upsertedItems.addAll(items) }
override suspend fun deleteById(id: Int) { deletedIds.add(id) }
}
// endregion
}
================================================
FILE: data/src/test/java/com/klee/sapio/data/InstalledApplicationsRepositoryTest.kt
================================================
package com.klee.sapio.data
import android.content.pm.ApplicationInfo
import android.os.Build
import com.klee.sapio.data.repository.InstalledApplicationsRepository
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.M])
class InstalledApplicationsRepositoryTest {
private lateinit var repository: InstalledApplicationsRepository
@Before
fun setUp() {
repository = InstalledApplicationsRepository(RuntimeEnvironment.getApplication())
}
@Test
fun `isSystemApp returns true when FLAG_SYSTEM is set`() {
val info = ApplicationInfo().apply { flags = ApplicationInfo.FLAG_SYSTEM }
assertTrue(repository.isSystemApp(info))
}
@Test
fun `isSystemApp returns false when FLAG_SYSTEM is not set`() {
val info = ApplicationInfo().apply { flags = 0 }
assertFalse(repository.isSystemApp(info))
}
@Test
fun `isSystemApp returns false for user-installed app with other flags`() {
val info = ApplicationInfo().apply { flags = ApplicationInfo.FLAG_INSTALLED }
assertFalse(repository.isSystemApp(info))
}
@Test
fun `isGmsRelated returns true for package ending with gms`() {
val info = ApplicationInfo().apply { packageName = "com.google.android.gms" }
assertTrue(repository.isGmsRelated(info))
}
@Test
fun `isGmsRelated returns true for com android vending`() {
val info = ApplicationInfo().apply { packageName = "com.android.vending" }
assertTrue(repository.isGmsRelated(info))
}
@Test
fun `isGmsRelated returns false for regular package`() {
val info = ApplicationInfo().apply { packageName = "org.mozilla.firefox" }
assertFalse(repository.isGmsRelated(info))
}
@Test
fun `isGmsRelated returns false for package that contains gms but does not end with it`() {
val info = ApplicationInfo().apply { packageName = "com.google.gms.something" }
assertFalse(repository.isGmsRelated(info))
}
@Test
fun `isGmsRelated returns false for empty package name`() {
val info = ApplicationInfo().apply { packageName = "" }
assertFalse(repository.isGmsRelated(info))
}
}
================================================
FILE: detekt.yml
================================================
# Naming rules
naming:
ConstructorParameterNaming:
active: false
VariableNaming:
active: false
FunctionNaming:
active: true
ignoreAnnotated: ['Composable']
TopLevelPropertyNaming:
active: true
constantPattern: '[A-Z][A-Za-z0-9]*'
# Style rules
style:
ForbiddenComment:
active: false
ReturnCount:
excludeGuardClauses: true
MagicNumber:
active: true
ignorePropertyDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
# Complexity rules
complexity:
TooManyFunctions:
ignorePrivate: true
LongMethod:
ignoreAnnotated: ['Composable']
================================================
FILE: domain/build.gradle
================================================
plugins {
id 'org.jetbrains.kotlin.jvm'
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(17)
}
dependencies {
implementation libs.kotlinx.coroutines.android
implementation libs.javax.inject
testImplementation libs.junit
testImplementation libs.kotlinx.coroutines.test
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/AppSettings.kt
================================================
package com.klee.sapio.domain
interface AppSettings {
fun getUnsafeConfigurationLevel(): Int
fun isUnsafeConfigurationEnabled(): Boolean
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/CheckFdroidAvailabilityUseCase.kt
================================================
package com.klee.sapio.domain
import javax.inject.Inject
open class CheckFdroidAvailabilityUseCase @Inject constructor(
private val fdroidAvailabilityChecker: FdroidAvailabilityChecker
) {
open suspend operator fun invoke(packageName: String): Boolean {
return fdroidAvailabilityChecker.isAvailable(packageName) ?: false
}
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/DeviceAppCacheRepository.kt
================================================
package com.klee.sapio.domain
import com.klee.sapio.domain.model.CachedDeviceApp
interface DeviceAppCacheRepository {
suspend fun getAll(): List
suspend fun replaceAll(items: List)
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/DeviceInfo.kt
================================================
package com.klee.sapio.domain
interface DeviceInfo {
fun getGmsType(): Int
fun isUnsafe(): Int
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/EvaluateAppUseCase.kt
================================================
package com.klee.sapio.domain
import com.klee.sapio.domain.model.Icon
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.domain.model.UploadEvaluation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
open class EvaluateAppUseCase @Inject constructor(
private val evaluationRepository: EvaluationRepository,
private val deviceInfo: DeviceInfo
) {
open suspend operator fun invoke(
app: InstalledApplication,
rating: Int
): Result {
val existingIcons = getExistingIcons(app)
val uploadedIcons = uploadIcon(app)
return when {
uploadedIcons.isEmpty() ->
Result.failure(IllegalStateException("Failed to upload icon"))
!submitEvaluation(app, uploadedIcons[0].id, rating) ->
Result.failure(IllegalStateException("Failed to add evaluation"))
else -> {
existingIcons.forEach { deleteIcon(it.id) }
Result.success(Unit)
}
}
}
private suspend fun uploadIcon(app: InstalledApplication): List {
return evaluationRepository.uploadIcon(app.packageName).getOrDefault(emptyList())
}
private suspend fun deleteIcon(id: Int) {
evaluationRepository.deleteIcon(id)
}
private suspend fun submitEvaluation(app: InstalledApplication, iconId: Int, rating: Int): Boolean {
val newEvaluation = UploadEvaluation(
app.name,
app.packageName,
iconId,
rating,
deviceInfo.getGmsType(),
deviceInfo.isUnsafe()
)
return evaluationRepository.addEvaluation(newEvaluation).isSuccess
}
private suspend fun getExistingIcons(app: InstalledApplication): List {
return withContext(Dispatchers.IO) {
evaluationRepository.existingIcon("${app.packageName}.png").getOrDefault(emptyList())
}
}
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/EvaluationRepository.kt
================================================
package com.klee.sapio.domain
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.domain.model.EvaluationRecord
import com.klee.sapio.domain.model.Icon
import com.klee.sapio.domain.model.UploadEvaluation
interface EvaluationRepository {
suspend fun listLatestEvaluations(pageNumber: Int): Result>
suspend fun searchEvaluations(pattern: String): Result>
suspend fun addEvaluation(evaluation: UploadEvaluation): Result
suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int): Result
suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int): Result
suspend fun existingEvaluations(packageName: String): Result>
suspend fun uploadIcon(packageName: String): Result>
suspend fun existingIcon(iconName: String): Result>
suspend fun deleteIcon(id: Int): Result
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/FdroidAvailabilityChecker.kt
================================================
package com.klee.sapio.domain
interface FdroidAvailabilityChecker {
suspend fun isAvailable(packageName: String): Boolean?
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/FetchAppEvaluationUseCase.kt
================================================
package com.klee.sapio.domain
import com.klee.sapio.domain.model.Evaluation
import javax.inject.Inject
open class FetchAppEvaluationUseCase @Inject constructor(
private val evaluationRepository: EvaluationRepository
) {
open suspend operator fun invoke(
packageName: String,
gmsType: Int,
userType: Int
): Result {
return evaluationRepository.fetchEvaluation(packageName, gmsType, userType)
}
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/FetchIconUrlUseCase.kt
================================================
package com.klee.sapio.domain
import javax.inject.Inject
open class FetchIconUrlUseCase @Inject constructor(
private val evaluationRepository: EvaluationRepository
) {
open suspend operator fun invoke(packageName: String): Result {
return evaluationRepository.existingIcon("$packageName.png")
.map { icons -> icons.firstOrNull()?.url.orEmpty() }
}
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/InstalledApplicationsDataSource.kt
================================================
package com.klee.sapio.domain
import com.klee.sapio.domain.model.InstalledApplication
interface InstalledApplicationsDataSource {
fun listInstalledApplications(): List
fun getInstalledApplication(packageName: String): InstalledApplication?
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/ListLatestEvaluationsUseCase.kt
================================================
package com.klee.sapio.domain
import com.klee.sapio.domain.model.Evaluation
import javax.inject.Inject
open class ListLatestEvaluationsUseCase @Inject constructor(
private val evaluationRepository: EvaluationRepository
) {
open suspend operator fun invoke(pageNumber: Int): Result> {
return evaluationRepository.listLatestEvaluations(pageNumber)
}
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/SearchEvaluationUseCase.kt
================================================
package com.klee.sapio.domain
import com.klee.sapio.domain.model.Evaluation
import javax.inject.Inject
open class SearchEvaluationUseCase @Inject constructor(
private val evaluationRepository: EvaluationRepository
) {
open suspend operator fun invoke(pattern: String): Result> {
return evaluationRepository.searchEvaluations(pattern)
}
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/model/CachedDeviceApp.kt
================================================
package com.klee.sapio.domain.model
data class CachedDeviceApp(
val packageName: String,
val rating: Int?,
val cachedAt: Long
)
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/model/DeviceProfile.kt
================================================
package com.klee.sapio.domain.model
object GmsType {
const val MICROG = 1
const val BARE_AOSP = 2
const val GOOGLE_PLAY_SERVICES = 3
}
object UserType {
const val SECURE = 3
const val UNSAFE = 4
}
================================================
FILE: domain/src/main/java/com/klee/sapio/domain/model/Models.kt
================================================
package com.klee.sapio.domain.model
import java.util.Date
data class Evaluation(
val name: String,
val packageName: String,
val iconUrl: String?,
val rating: Int,
val microg: Int,
val secure: Int,
val updatedAt: Date?,
val createdAt: Date?,
val publishedAt: Date?,
val versionName: String?
)
data class UploadEvaluation(
val name: String,
val packageName: String,
val icon: Int?,
val rating: Int,
val microg: Int,
val rooted: Int
)
data class Icon(
val id: Int,
val name: String,
val url: String
)
data class EvaluationRecord(
val id: Int,
val evaluation: Evaluation
)
data class InstalledApplication(
val name: String,
val packageName: String
)
object EvaluationRating {
const val GOOD = 1
const val AVERAGE = 2
const val BAD = 3
}
================================================
FILE: domain/src/test/java/com/klee/sapio/domain/CheckFdroidAvailabilityUseCaseTest.kt
================================================
package com.klee.sapio.domain
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CheckFdroidAvailabilityUseCaseTest {
@Test
fun `returns true when checker reports available`() = runTest {
val useCase = CheckFdroidAvailabilityUseCase(FakeFdroidChecker(available = true))
assertTrue(useCase("com.test.app"))
}
@Test
fun `returns false when checker reports unavailable`() = runTest {
val useCase = CheckFdroidAvailabilityUseCase(FakeFdroidChecker(available = false))
assertFalse(useCase("com.test.app"))
}
@Test
fun `returns false when checker reports network error`() = runTest {
val useCase = CheckFdroidAvailabilityUseCase(FakeFdroidChecker(available = null))
assertFalse(useCase("com.test.app"))
}
@Test
fun `passes package name to checker`() = runTest {
val checker = RecordingFdroidChecker()
val useCase = CheckFdroidAvailabilityUseCase(checker)
useCase("org.mozilla.firefox")
assertTrue(checker.lastCheckedPackage == "org.mozilla.firefox")
}
private class FakeFdroidChecker(private val available: Boolean?) : FdroidAvailabilityChecker {
override suspend fun isAvailable(packageName: String) = available
}
private class RecordingFdroidChecker : FdroidAvailabilityChecker {
var lastCheckedPackage: String = ""
override suspend fun isAvailable(packageName: String): Boolean? {
lastCheckedPackage = packageName
return false
}
}
}
================================================
FILE: domain/src/test/java/com/klee/sapio/domain/EvaluateAppUseCaseTest.kt
================================================
package com.klee.sapio.domain
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.domain.model.EvaluationRecord
import com.klee.sapio.domain.model.Icon
import com.klee.sapio.domain.model.InstalledApplication
import com.klee.sapio.domain.model.UploadEvaluation
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class EvaluateAppUseCaseTest {
private val app = InstalledApplication(name = "Test App", packageName = "com.test.app")
private val icon = Icon(id = 42, name = "com.test.app.png", url = "http://icon/test.png")
private val existingIcon = Icon(id = 7, name = "com.test.app.png", url = "http://icon/old.png")
@Test
fun `returns failure when icon upload returns empty list`() = runTest {
val repo = fakeRepo(uploadIconResult = Result.success(emptyList()))
val useCase = EvaluateAppUseCase(repo, FakeDeviceInfo())
val result = useCase(app, rating = 1)
assertTrue(result.isFailure)
}
@Test
fun `returns failure when icon upload fails`() = runTest {
val repo = fakeRepo(uploadIconResult = Result.failure(RuntimeException("network error")))
val useCase = EvaluateAppUseCase(repo, FakeDeviceInfo())
val result = useCase(app, rating = 1)
assertTrue(result.isFailure)
}
@Test
fun `returns failure when add evaluation fails`() = runTest {
val repo = fakeRepo(
uploadIconResult = Result.success(listOf(icon)),
addEvaluationResult = Result.failure(RuntimeException("server error"))
)
val useCase = EvaluateAppUseCase(repo, FakeDeviceInfo())
val result = useCase(app, rating = 1)
assertTrue(result.isFailure)
}
@Test
fun `returns success when upload and evaluation succeed`() = runTest {
val repo = fakeRepo(
uploadIconResult = Result.success(listOf(icon)),
addEvaluationResult = Result.success(Unit)
)
val useCase = EvaluateAppUseCase(repo, FakeDeviceInfo())
val result = useCase(app, rating = 1)
assertTrue(result.isSuccess)
}
@Test
fun `deletes existing icons on success`() = runTest {
val repo = fakeRepo(
uploadIconResult = Result.success(listOf(icon)),
addEvaluationResult = Result.success(Unit),
existingIconResult = Result.success(listOf(existingIcon))
)
val useCase = EvaluateAppUseCase(repo, FakeDeviceInfo())
useCase(app, rating = 1)
assertTrue(repo.deletedIconIds.contains(existingIcon.id))
}
@Test
fun `does not fail when there are no existing icons to delete`() = runTest {
val repo = fakeRepo(
uploadIconResult = Result.success(listOf(icon)),
addEvaluationResult = Result.success(Unit),
existingIconResult = Result.success(emptyList())
)
val useCase = EvaluateAppUseCase(repo, FakeDeviceInfo())
val result = useCase(app, rating = 1)
assertTrue(result.isSuccess)
assertTrue(repo.deletedIconIds.isEmpty())
}
@Test
fun `does not delete icons when evaluation fails`() = runTest {
val repo = fakeRepo(
uploadIconResult = Result.success(listOf(icon)),
addEvaluationResult = Result.failure(RuntimeException()),
existingIconResult = Result.success(listOf(existingIcon))
)
val useCase = EvaluateAppUseCase(repo, FakeDeviceInfo())
useCase(app, rating = 1)
assertFalse(repo.deletedIconIds.contains(existingIcon.id))
}
@Test
fun `uses device gmsType and userType when building evaluation`() = runTest {
val deviceInfo = FakeDeviceInfo(gmsType = 2, userType = 4)
val repo = fakeRepo(
uploadIconResult = Result.success(listOf(icon)),
addEvaluationResult = Result.success(Unit)
)
val useCase = EvaluateAppUseCase(repo, deviceInfo)
useCase(app, rating = 1)
val submitted = repo.lastSubmittedEvaluation
assertTrue(submitted?.microg == 2)
assertTrue(submitted?.rooted == 4)
}
private fun fakeRepo(
uploadIconResult: Result> = Result.success(listOf(icon)),
addEvaluationResult: Result = Result.success(Unit),
existingIconResult: Result> = Result.success(emptyList())
) = FakeEvaluationRepository(
uploadIconResult = uploadIconResult,
addEvaluationResult = addEvaluationResult,
existingIconResult = existingIconResult
)
private class FakeEvaluationRepository(
private val uploadIconResult: Result>,
private val addEvaluationResult: Result,
private val existingIconResult: Result>
) : EvaluationRepository {
val deletedIconIds = mutableListOf()
var lastSubmittedEvaluation: UploadEvaluation? = null
override suspend fun uploadIcon(packageName: String) = uploadIconResult
override suspend fun existingIcon(iconName: String) = existingIconResult
override suspend fun addEvaluation(evaluation: UploadEvaluation): Result {
lastSubmittedEvaluation = evaluation
return addEvaluationResult
}
override suspend fun deleteIcon(id: Int): Result {
deletedIconIds.add(id)
return Result.success(Unit)
}
override suspend fun listLatestEvaluations(pageNumber: Int) = Result.success(emptyList())
override suspend fun searchEvaluations(pattern: String) = Result.success(emptyList())
override suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int) = Result.success(Unit)
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int) = Result.success(null)
override suspend fun existingEvaluations(packageName: String) = Result.success(emptyList())
}
private class FakeDeviceInfo(
private val gmsType: Int = 1,
private val userType: Int = 3
) : DeviceInfo {
override fun getGmsType() = gmsType
override fun isUnsafe() = userType
}
}
================================================
FILE: domain/src/test/java/com/klee/sapio/domain/FetchAppEvaluationUseCaseTest.kt
================================================
package com.klee.sapio.domain
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.domain.model.EvaluationRecord
import com.klee.sapio.domain.model.Icon
import com.klee.sapio.domain.model.UploadEvaluation
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Date
class FetchAppEvaluationUseCaseTest {
@Test
fun `returns evaluation when found`() = runTest {
val evaluation = evaluation()
val useCase = FetchAppEvaluationUseCase(fakeRepo(Result.success(evaluation)))
val result = useCase("com.test.app", gmsType = 1, userType = 3)
assertTrue(result.isSuccess)
assertEquals(evaluation, result.getOrNull())
}
@Test
fun `returns null when not found`() = runTest {
val useCase = FetchAppEvaluationUseCase(fakeRepo(Result.success(null)))
val result = useCase("com.test.app", gmsType = 1, userType = 3)
assertTrue(result.isSuccess)
assertNull(result.getOrNull())
}
@Test
fun `propagates failure from repository`() = runTest {
val error = RuntimeException("network error")
val useCase = FetchAppEvaluationUseCase(fakeRepo(Result.failure(error)))
val result = useCase("com.test.app", gmsType = 1, userType = 3)
assertTrue(result.isFailure)
assertEquals(error, result.exceptionOrNull())
}
@Test
fun `passes correct arguments to repository`() = runTest {
val repo = RecordingFakeRepo()
val useCase = FetchAppEvaluationUseCase(repo)
useCase("com.test.app", gmsType = 2, userType = 4)
assertEquals("com.test.app", repo.lastPackageName)
assertEquals(2, repo.lastGmsType)
assertEquals(4, repo.lastUserType)
}
private fun fakeRepo(result: Result) = object : EvaluationRepository {
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int) = result
override suspend fun listLatestEvaluations(pageNumber: Int) = Result.success(emptyList())
override suspend fun searchEvaluations(pattern: String) = Result.success(emptyList())
override suspend fun addEvaluation(evaluation: UploadEvaluation) = Result.success(Unit)
override suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int) = Result.success(Unit)
override suspend fun existingEvaluations(packageName: String) = Result.success(emptyList())
override suspend fun uploadIcon(packageName: String) = Result.success(emptyList())
override suspend fun existingIcon(iconName: String) = Result.success(emptyList())
override suspend fun deleteIcon(id: Int) = Result.success(Unit)
}
private class RecordingFakeRepo : EvaluationRepository {
var lastPackageName: String = ""
var lastGmsType: Int = -1
var lastUserType: Int = -1
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int): Result {
lastPackageName = appPackageName
lastGmsType = gmsType
lastUserType = userType
return Result.success(null)
}
override suspend fun listLatestEvaluations(pageNumber: Int) = Result.success(emptyList())
override suspend fun searchEvaluations(pattern: String) = Result.success(emptyList())
override suspend fun addEvaluation(evaluation: UploadEvaluation) = Result.success(Unit)
override suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int) = Result.success(Unit)
override suspend fun existingEvaluations(packageName: String) = Result.success(emptyList())
override suspend fun uploadIcon(packageName: String) = Result.success(emptyList())
override suspend fun existingIcon(iconName: String) = Result.success(emptyList())
override suspend fun deleteIcon(id: Int) = Result.success(Unit)
}
private fun evaluation() = Evaluation(
name = "Test App", packageName = "com.test.app", iconUrl = null,
rating = 1, microg = 1, secure = 3,
updatedAt = Date(), createdAt = Date(), publishedAt = null, versionName = null
)
}
================================================
FILE: domain/src/test/java/com/klee/sapio/domain/FetchIconUrlUseCaseTest.kt
================================================
package com.klee.sapio.domain
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.domain.model.EvaluationRecord
import com.klee.sapio.domain.model.Icon
import com.klee.sapio.domain.model.UploadEvaluation
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class FetchIconUrlUseCaseTest {
@Test
fun `returns url of first icon when found`() = runTest {
val icon = Icon(id = 1, name = "com.test.app.png", url = "http://icon/test.png")
val useCase = FetchIconUrlUseCase(fakeRepo(Result.success(listOf(icon))))
val result = useCase("com.test.app")
assertTrue(result.isSuccess)
assertEquals("http://icon/test.png", result.getOrNull())
}
@Test
fun `returns empty string when icon list is empty`() = runTest {
val useCase = FetchIconUrlUseCase(fakeRepo(Result.success(emptyList())))
val result = useCase("com.test.app")
assertTrue(result.isSuccess)
assertEquals("", result.getOrNull())
}
@Test
fun `appends png extension when querying`() = runTest {
val repo = RecordingFakeRepo()
val useCase = FetchIconUrlUseCase(repo)
useCase("com.test.app")
assertEquals("com.test.app.png", repo.lastIconName)
}
@Test
fun `returns url of first icon when multiple icons exist`() = runTest {
val icons = listOf(
Icon(id = 1, name = "com.test.app.png", url = "http://icon/first.png"),
Icon(id = 2, name = "com.test.app.png", url = "http://icon/second.png")
)
val useCase = FetchIconUrlUseCase(fakeRepo(Result.success(icons)))
val result = useCase("com.test.app")
assertEquals("http://icon/first.png", result.getOrNull())
}
@Test
fun `propagates failure from repository`() = runTest {
val error = RuntimeException("network error")
val useCase = FetchIconUrlUseCase(fakeRepo(Result.failure(error)))
val result = useCase("com.test.app")
assertTrue(result.isFailure)
}
private fun fakeRepo(result: Result>) = object : EvaluationRepository {
override suspend fun existingIcon(iconName: String) = result
override suspend fun listLatestEvaluations(pageNumber: Int) = Result.success(emptyList())
override suspend fun searchEvaluations(pattern: String) = Result.success(emptyList())
override suspend fun addEvaluation(evaluation: UploadEvaluation) = Result.success(Unit)
override suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int) = Result.success(Unit)
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int) = Result.success(null)
override suspend fun existingEvaluations(packageName: String) = Result.success(emptyList())
override suspend fun uploadIcon(packageName: String) = Result.success(emptyList())
override suspend fun deleteIcon(id: Int) = Result.success(Unit)
}
private class RecordingFakeRepo : EvaluationRepository {
var lastIconName: String = ""
override suspend fun existingIcon(iconName: String): Result> {
lastIconName = iconName
return Result.success(emptyList())
}
override suspend fun listLatestEvaluations(pageNumber: Int) = Result.success(emptyList())
override suspend fun searchEvaluations(pattern: String) = Result.success(emptyList())
override suspend fun addEvaluation(evaluation: UploadEvaluation) = Result.success(Unit)
override suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int) = Result.success(Unit)
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int) = Result.success(null)
override suspend fun existingEvaluations(packageName: String) = Result.success(emptyList())
override suspend fun uploadIcon(packageName: String) = Result.success(emptyList())
override suspend fun deleteIcon(id: Int) = Result.success(Unit)
}
}
================================================
FILE: domain/src/test/java/com/klee/sapio/domain/ListLatestEvaluationsUseCaseTest.kt
================================================
package com.klee.sapio.domain
import com.klee.sapio.domain.model.Evaluation
import com.klee.sapio.domain.model.EvaluationRecord
import com.klee.sapio.domain.model.Icon
import com.klee.sapio.domain.model.UploadEvaluation
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Date
class ListLatestEvaluationsUseCaseTest {
@Test
fun `returns list from repository on success`() = runTest {
val evaluations = listOf(
evaluation(name = "App A", packageName = "com.a"),
evaluation(name = "App B", packageName = "com.b")
)
val useCase = ListLatestEvaluationsUseCase(fakeRepo(Result.success(evaluations)))
val result = useCase(pageNumber = 1)
assertTrue(result.isSuccess)
assertEquals(evaluations, result.getOrNull())
}
@Test
fun `returns empty list when repository returns empty`() = runTest {
val useCase = ListLatestEvaluationsUseCase(fakeRepo(Result.success(emptyList())))
val result = useCase(pageNumber = 1)
assertTrue(result.isSuccess)
assertEquals(emptyList(), result.getOrNull())
}
@Test
fun `propagates failure from repository`() = runTest {
val error = RuntimeException("network error")
val useCase = ListLatestEvaluationsUseCase(fakeRepo(Result.failure(error)))
val result = useCase(pageNumber = 1)
assertTrue(result.isFailure)
assertEquals(error, result.exceptionOrNull())
}
@Test
fun `passes page number to repository`() = runTest {
val repo = RecordingFakeRepo()
val useCase = ListLatestEvaluationsUseCase(repo)
useCase(pageNumber = 3)
assertEquals(3, repo.lastPageNumber)
}
private fun fakeRepo(result: Result>) = object : EvaluationRepository {
override suspend fun listLatestEvaluations(pageNumber: Int) = result
override suspend fun searchEvaluations(pattern: String) = Result.success(emptyList())
override suspend fun addEvaluation(evaluation: UploadEvaluation) = Result.success(Unit)
override suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int) = Result.success(Unit)
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int) = Result.success(null)
override suspend fun existingEvaluations(packageName: String) = Result.success(emptyList())
override suspend fun uploadIcon(packageName: String) = Result.success(emptyList())
override suspend fun existingIcon(iconName: String) = Result.success(emptyList())
override suspend fun deleteIcon(id: Int) = Result.success(Unit)
}
private class RecordingFakeRepo : EvaluationRepository {
var lastPageNumber: Int = -1
override suspend fun listLatestEvaluations(pageNumber: Int): Result> {
lastPageNumber = pageNumber
return Result.success(emptyList())
}
override suspend fun searchEvaluations(pattern: String) = Result.success(emptyList())
override suspend fun addEvaluation(evaluation: UploadEvaluation) = Result.success(Unit)
override suspend fun updateEvaluation(evaluation: UploadEvaluation, id: Int) = Result.success(Unit)
override suspend fun fetchEvaluation(appPackageName: String, gmsType: Int, userType: Int) = Result.success(null)
override suspend fun existingEvaluations(packageName: String) = Result.success(emptyList())
override suspend fun uploadIcon(packageName: String) = Result.success(emptyList