v0.6.4
This is a test if updates work
This commit is contained in:
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{kt,kts}]
|
||||
ktlint_code_style = android_studio
|
||||
indent_size = 4
|
||||
ij_kotlin_name_count_to_use_star_import = 999
|
||||
ij_kotlin_name_count_to_use_star_import_for_members = 999
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. Pixel 6]
|
||||
- OS: [e.g. Android 12L]
|
||||
- Version [e.g. v0.5.1]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
26
.github/ISSUE_TEMPLATE/quick-crash-report.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/quick-crash-report.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Quick crash report
|
||||
about: Create a report to help us improve
|
||||
title: "[Crash]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. Pixel 6]
|
||||
- OS: [e.g. Android 12L]
|
||||
- Version [e.g. v0.5.1]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here. Like what are the changes in settings that you made.
|
||||
77
.github/workflows/build_debug.yml
vendored
Normal file
77
.github/workflows/build_debug.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Build Debug APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '**.yml'
|
||||
- '**.xml'
|
||||
pull_request_review:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
types: submitted
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/wrapper-validation-action@v3
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: 'adopt'
|
||||
cache: gradle
|
||||
|
||||
- name: Grant execution permission to Gradle Wrapper
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Format Code
|
||||
run: ./gradlew ktlintFormat
|
||||
|
||||
- name: Build Debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Sign Apk
|
||||
continue-on-error: true
|
||||
id: sign_apk
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDir: app/build/outputs/apk/debug
|
||||
signingKeyBase64: ${{ secrets.KEY_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASS }}
|
||||
keyPassword: ${{ secrets.KEYSTORE_PASS }}
|
||||
|
||||
- name: Remove file that aren't signed
|
||||
continue-on-error: true
|
||||
run: |
|
||||
ls | grep 'signed\.apk$' && find . -type f -name '*.apk' ! -name '*-signed.apk' -delete
|
||||
|
||||
- name: Upload the APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debug
|
||||
path: app/build/outputs/apk/debug/app-debug*.apk
|
||||
72
.github/workflows/release_build.yml
vendored
Normal file
72
.github/workflows/release_build.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Build Release APK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: "release-build"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/wrapper-validation-action@v3
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: 'adopt'
|
||||
cache: gradle
|
||||
|
||||
- name: Grant execution permission to Gradle Wrapper
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Build Release APK
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Checks
|
||||
run: find . -type f -name "*.apk"
|
||||
|
||||
- uses: r0adkll/sign-android-release@v1
|
||||
name: Signing APK
|
||||
id: sign_app
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/release
|
||||
signingKeyBase64: ${{ secrets.KEY_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASS }}
|
||||
keyPassword: ${{ secrets.KEYSTORE_PASS }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
name: Create Release
|
||||
id: publish_release
|
||||
with:
|
||||
files: ${{steps.sign_app.outputs.signedReleaseFile}}
|
||||
draft: true
|
||||
prerelease: false
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Signed APK
|
||||
path: ${{steps.sign_app.outputs.signedReleaseFile}}
|
||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/app/build/
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/.idea/
|
||||
/build-logic/structure/build/
|
||||
/core-datastore/build/
|
||||
/app/release/
|
||||
/app/alpha/
|
||||
/.kotlin/
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
15
STATUS.md
Normal file
15
STATUS.md
Normal file
@@ -0,0 +1,15 @@
|
||||
_17-Aug-2023_
|
||||
# Project Status
|
||||
|
||||
I was holding back releases, because I was re-writing the whole structure of the app. But I think this is taking too long.
|
||||
It is only natural that users will grow impatient for an update, thats why I will be releasing new versions soon.
|
||||
|
||||
## Why the delay:
|
||||
- I had exams and submissions in my college
|
||||
- I am really unwell mentally
|
||||
- I was not able to create a solid structure for the new backend
|
||||
|
||||
## What now?
|
||||
- Next release will be on **19-Aug**, and will contain many bug fixes and stability improvements.
|
||||
- All the [progress](https://github.com/Droid-ify/client/pull/309) made in past months is still here and will help in future.
|
||||
- We will be missing on the new index format introduced by fdroid for some future releases.
|
||||
105
app/build.gradle.kts
Normal file
105
app/build.gradle.kts
Normal file
@@ -0,0 +1,105 @@
|
||||
plugins {
|
||||
alias(libs.plugins.looker.android.application)
|
||||
alias(libs.plugins.looker.hilt.work)
|
||||
alias(libs.plugins.looker.lint)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.looker.droidify"
|
||||
defaultConfig {
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
sourceSets.forEach { source ->
|
||||
val javaDir = source.java.srcDirs.find { it.name == "java" }
|
||||
source.java {
|
||||
srcDir(File(javaDir?.parentFile, "kotlin"))
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
resValue("string", "application_name", "Droid-ify-Debug")
|
||||
}
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
resValue("string", "application_name", "Droid-ify")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard.pro"
|
||||
)
|
||||
}
|
||||
create("alpha") {
|
||||
initWith(getByName("debug"))
|
||||
applicationIdSuffix = ".alpha"
|
||||
resValue("string", "application_name", "Droid-ify Alpha")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard.pro"
|
||||
)
|
||||
isDebuggable = true
|
||||
isMinifyEnabled = true
|
||||
}
|
||||
all {
|
||||
buildConfigField(
|
||||
type = "String",
|
||||
name = "VERSION_NAME",
|
||||
value = "\"v${DefaultConfig.versionName}\""
|
||||
)
|
||||
}
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += listOf(
|
||||
"/DebugProbesKt.bin",
|
||||
"/kotlin/**.kotlin_builtins",
|
||||
"/kotlin/**.kotlin_metadata",
|
||||
"/META-INF/**.kotlin_module",
|
||||
"/META-INF/**.pro",
|
||||
"/META-INF/**.version",
|
||||
"/META-INF/versions/9/previous-**.bin"
|
||||
)
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
resValues = true
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
modules(
|
||||
Modules.coreDomain,
|
||||
// Modules.coreData,
|
||||
Modules.coreCommon,
|
||||
Modules.coreNetwork,
|
||||
Modules.coreDatastore,
|
||||
Modules.coreDI,
|
||||
Modules.installer,
|
||||
)
|
||||
|
||||
implementation(libs.android.material)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewModel)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.androidx.sqlite.ktx)
|
||||
implementation(libs.coil.kt)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.jackson.core)
|
||||
implementation(libs.image.viewer)
|
||||
|
||||
// debugImplementation(libs.leakcanary)
|
||||
}
|
||||
9
app/proguard.pro
vendored
Normal file
9
app/proguard.pro
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
-dontobfuscate
|
||||
|
||||
# Disable ServiceLoader reproducibility-breaking optimizations
|
||||
-keep class kotlinx.coroutines.CoroutineExceptionHandler
|
||||
-keep class kotlinx.coroutines.internal.MainDispatcherFactory
|
||||
|
||||
-dontwarn kotlinx.serialization.KSerializer
|
||||
-dontwarn kotlinx.serialization.Serializable
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
189
app/src/main/AndroidManifest.xml
Normal file
189
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,189 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:allowBackup="true"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/application_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/MainTheme"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<receiver
|
||||
android:name=".MainApplication$BootReceiver"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SHOW_APP_INFO" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="fdroid.app" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="details"
|
||||
android:scheme="market" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="f-droid.org" />
|
||||
<data android:host="www.f-droid.org" />
|
||||
<data android:host="staging.f-droid.org" />
|
||||
<data android:pathPattern="/app/.*" />
|
||||
<data android:pathPattern="/packages/.*" />
|
||||
<data android:pathPattern="/.*/packages/.*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="apt.izzysoft.de" />
|
||||
<data android:pathPattern="/fdroid/index/apk/.*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<data android:host="droidify.eu.org" />
|
||||
<data android:pathPattern="/app/.*" />
|
||||
</intent-filter>
|
||||
|
||||
<!--Adding repo with special url-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="fdroidrepo" />
|
||||
<data android:scheme="fdroidrepos" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".service.SyncService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name=".service.SyncService$Job"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||
|
||||
<service
|
||||
android:name=".service.DownloadService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<receiver
|
||||
android:name="com.looker.installer.installers.session.SessionInstallerReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="rikka.shizuku.ShizukuProvider"
|
||||
android:authorities="${applicationId}.shizuku"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:multiprocess="false"
|
||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||
|
||||
<provider
|
||||
android:name="com.looker.core.common.cache.Cache$Provider"
|
||||
android:authorities="${applicationId}.provider.cache"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
29
app/src/main/kotlin/com/looker/droidify/MainActivity.kt
Normal file
29
app/src/main/kotlin/com/looker/droidify/MainActivity.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.looker.droidify
|
||||
|
||||
import android.content.Intent
|
||||
import com.looker.core.common.getInstallPackageName
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ScreenActivity() {
|
||||
companion object {
|
||||
const val ACTION_UPDATES = "${BuildConfig.APPLICATION_ID}.intent.action.UPDATES"
|
||||
const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
|
||||
const val EXTRA_CACHE_FILE_NAME =
|
||||
"${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
|
||||
}
|
||||
|
||||
override fun handleIntent(intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates)
|
||||
ACTION_INSTALL -> handleSpecialIntent(
|
||||
SpecialIntent.Install(
|
||||
intent.getInstallPackageName,
|
||||
intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
|
||||
)
|
||||
)
|
||||
|
||||
else -> super.handleIntent(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
276
app/src/main/kotlin/com/looker/droidify/MainApplication.kt
Normal file
276
app/src/main/kotlin/com/looker/droidify/MainApplication.kt
Normal file
@@ -0,0 +1,276 @@
|
||||
package com.looker.droidify
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.NetworkType
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.looker.core.common.Constants
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.common.extension.getInstalledPackagesCompat
|
||||
import com.looker.core.common.extension.jobScheduler
|
||||
import com.looker.core.common.log
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.core.datastore.model.AutoSync
|
||||
import com.looker.core.datastore.model.InstallerType
|
||||
import com.looker.core.datastore.model.ProxyPreference
|
||||
import com.looker.core.datastore.model.ProxyType
|
||||
import com.looker.droidify.content.ProductPreferences
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.index.RepositoryUpdater
|
||||
import com.looker.droidify.receivers.InstalledAppReceiver
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import com.looker.droidify.sync.SyncPreference
|
||||
import com.looker.droidify.sync.toJobNetworkType
|
||||
import com.looker.droidify.utility.extension.toInstalledItem
|
||||
import com.looker.droidify.work.CleanUpWorker
|
||||
import com.looker.installer.InstallManager
|
||||
import com.looker.installer.installers.root.RootPermissionHandler
|
||||
import com.looker.installer.installers.shizuku.ShizukuPermissionHandler
|
||||
import com.looker.network.Downloader
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.collectIndexed
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.INFINITE
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import com.looker.core.common.R as CommonR
|
||||
|
||||
@HiltAndroidApp
|
||||
class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider {
|
||||
|
||||
private val parentJob = SupervisorJob()
|
||||
private val appScope = CoroutineScope(Dispatchers.Default + parentJob)
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var installer: InstallManager
|
||||
|
||||
@Inject
|
||||
lateinit var downloader: Downloader
|
||||
|
||||
@Inject
|
||||
lateinit var shizukuPermissionHandler: ShizukuPermissionHandler
|
||||
|
||||
@Inject
|
||||
lateinit var rootPermissionHandler: RootPermissionHandler
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val databaseUpdated = Database.init(this)
|
||||
ProductPreferences.init(this, appScope)
|
||||
RepositoryUpdater.init(appScope, downloader)
|
||||
listenApplications()
|
||||
checkLanguage()
|
||||
updatePreference()
|
||||
setupInstaller()
|
||||
|
||||
if (databaseUpdated) forceSyncAll()
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
appScope.cancel("Application Terminated")
|
||||
installer.close()
|
||||
}
|
||||
|
||||
private fun setupInstaller() {
|
||||
appScope.launch {
|
||||
launch {
|
||||
settingsRepository.get { installerType }.collect {
|
||||
if (it == InstallerType.SHIZUKU) handleShizukuInstaller()
|
||||
if (it == InstallerType.ROOT) {
|
||||
if (!rootPermissionHandler.isGranted) {
|
||||
settingsRepository.setInstallerType(InstallerType.Default)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
installer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.handleShizukuInstaller() = launch {
|
||||
shizukuPermissionHandler.state.collect { (isGranted, isAlive, _) ->
|
||||
if (isAlive && isGranted) {
|
||||
settingsRepository.setInstallerType(InstallerType.SHIZUKU)
|
||||
return@collect
|
||||
}
|
||||
if (isAlive) {
|
||||
settingsRepository.setInstallerType(InstallerType.Default)
|
||||
shizukuPermissionHandler.requestPermission()
|
||||
return@collect
|
||||
}
|
||||
settingsRepository.setInstallerType(InstallerType.Default)
|
||||
}
|
||||
}
|
||||
|
||||
private fun listenApplications() {
|
||||
registerReceiver(
|
||||
InstalledAppReceiver(packageManager),
|
||||
IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
)
|
||||
val installedItems =
|
||||
packageManager.getInstalledPackagesCompat()
|
||||
?.map { it.toInstalledItem() }
|
||||
?: return
|
||||
Database.InstalledAdapter.putAll(installedItems)
|
||||
}
|
||||
|
||||
private fun checkLanguage() {
|
||||
appScope.launch {
|
||||
val lastSetLanguage = settingsRepository.getInitial().language
|
||||
val systemSetLanguage = AppCompatDelegate.getApplicationLocales().toLanguageTags()
|
||||
if (systemSetLanguage != lastSetLanguage && lastSetLanguage != "system") {
|
||||
settingsRepository.setLanguage(systemSetLanguage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePreference() {
|
||||
appScope.launch {
|
||||
launch {
|
||||
settingsRepository.get { unstableUpdate }.drop(1).collect {
|
||||
forceSyncAll()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
settingsRepository.get { autoSync }.collectIndexed { index, syncMode ->
|
||||
// Don't update sync job on initial collect
|
||||
updateSyncJob(index > 0, syncMode)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
settingsRepository.get { cleanUpInterval }.drop(1).collect {
|
||||
if (it == INFINITE) {
|
||||
CleanUpWorker.removeAllSchedules(applicationContext)
|
||||
} else {
|
||||
CleanUpWorker.scheduleCleanup(applicationContext, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
settingsRepository.get { proxy }.collect(::updateProxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProxy(proxyPreference: ProxyPreference) {
|
||||
val type = proxyPreference.type
|
||||
val host = proxyPreference.host
|
||||
val port = proxyPreference.port
|
||||
val socketAddress = when (type) {
|
||||
ProxyType.DIRECT -> null
|
||||
ProxyType.HTTP, ProxyType.SOCKS -> {
|
||||
try {
|
||||
InetSocketAddress.createUnresolved(host, port)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
log(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
val androidProxyType = when (type) {
|
||||
ProxyType.DIRECT -> Proxy.Type.DIRECT
|
||||
ProxyType.HTTP -> Proxy.Type.HTTP
|
||||
ProxyType.SOCKS -> Proxy.Type.SOCKS
|
||||
}
|
||||
val determinedProxy = socketAddress?.let { Proxy(androidProxyType, it) } ?: Proxy.NO_PROXY
|
||||
downloader.setProxy(determinedProxy)
|
||||
}
|
||||
|
||||
private fun updateSyncJob(force: Boolean, autoSync: AutoSync) {
|
||||
if (autoSync == AutoSync.NEVER) {
|
||||
jobScheduler?.cancel(Constants.JOB_ID_SYNC)
|
||||
return
|
||||
}
|
||||
val jobScheduler = jobScheduler
|
||||
val syncConditions = when (autoSync) {
|
||||
AutoSync.ALWAYS -> SyncPreference(NetworkType.CONNECTED)
|
||||
AutoSync.WIFI_ONLY -> SyncPreference(NetworkType.UNMETERED)
|
||||
AutoSync.WIFI_PLUGGED_IN -> SyncPreference(NetworkType.UNMETERED, pluggedIn = true)
|
||||
else -> null
|
||||
}
|
||||
val isCompleted = jobScheduler?.allPendingJobs
|
||||
?.any { it.id == Constants.JOB_ID_SYNC } == false
|
||||
if ((force || isCompleted) && syncConditions != null) {
|
||||
val period = 12.hours.inWholeMilliseconds
|
||||
val job = SyncService.Job.create(
|
||||
context = this,
|
||||
periodMillis = period,
|
||||
networkType = syncConditions.toJobNetworkType(),
|
||||
isCharging = syncConditions.pluggedIn,
|
||||
isBatteryLow = syncConditions.batteryNotLow
|
||||
)
|
||||
jobScheduler?.schedule(job)
|
||||
}
|
||||
}
|
||||
|
||||
private fun forceSyncAll() {
|
||||
Database.RepositoryAdapter.getAll().forEach {
|
||||
if (it.lastModified.isNotEmpty() || it.entityTag.isNotEmpty()) {
|
||||
Database.RepositoryAdapter.put(it.copy(lastModified = "", entityTag = ""))
|
||||
}
|
||||
}
|
||||
Connection(SyncService::class.java, onBind = { connection, binder ->
|
||||
binder.sync(SyncService.SyncRequest.FORCE)
|
||||
connection.unbind(this)
|
||||
}).bind(this)
|
||||
}
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
@SuppressLint("UnsafeProtectedBroadcastReceiver")
|
||||
override fun onReceive(context: Context, intent: Intent) = Unit
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
val memoryCache = MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
|
||||
val diskCache = DiskCache.Builder()
|
||||
.directory(Cache.getImagesDir(this))
|
||||
.maxSizePercent(0.05)
|
||||
.build()
|
||||
|
||||
return ImageLoader.Builder(this)
|
||||
.memoryCache(memoryCache)
|
||||
.diskCache(diskCache)
|
||||
.error(CommonR.drawable.ic_cannot_load)
|
||||
.crossfade(350)
|
||||
.build()
|
||||
}
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
}
|
||||
308
app/src/main/kotlin/com/looker/droidify/ScreenActivity.kt
Normal file
308
app/src/main/kotlin/com/looker/droidify/ScreenActivity.kt
Normal file
@@ -0,0 +1,308 @@
|
||||
package com.looker.droidify
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.looker.core.common.DeeplinkType
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.deeplinkType
|
||||
import com.looker.core.common.extension.homeAsUp
|
||||
import com.looker.core.common.extension.inputManager
|
||||
import com.looker.core.common.requestNotificationPermission
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.extension.getThemeRes
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.droidify.database.CursorOwner
|
||||
import com.looker.droidify.ui.appDetail.AppDetailFragment
|
||||
import com.looker.droidify.ui.favourites.FavouritesFragment
|
||||
import com.looker.droidify.ui.repository.EditRepositoryFragment
|
||||
import com.looker.droidify.ui.repository.RepositoriesFragment
|
||||
import com.looker.droidify.ui.repository.RepositoryFragment
|
||||
import com.looker.droidify.ui.settings.SettingsFragment
|
||||
import com.looker.droidify.ui.tabsFragment.TabsFragment
|
||||
import com.looker.installer.InstallManager
|
||||
import com.looker.installer.model.installFrom
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
abstract class ScreenActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private const val STATE_FRAGMENT_STACK = "fragmentStack"
|
||||
}
|
||||
|
||||
sealed interface SpecialIntent {
|
||||
data object Updates : SpecialIntent
|
||||
class Install(val packageName: String?, val cacheFileName: String?) : SpecialIntent
|
||||
}
|
||||
|
||||
private val notificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
@Inject
|
||||
lateinit var installer: InstallManager
|
||||
|
||||
@Parcelize
|
||||
private class FragmentStackItem(
|
||||
val className: String, val arguments: Bundle?, val savedState: Fragment.SavedState?
|
||||
) : Parcelable
|
||||
|
||||
lateinit var cursorOwner: CursorOwner
|
||||
private set
|
||||
|
||||
private var onBackPressedCallback: OnBackPressedCallback? = null
|
||||
|
||||
private val fragmentStack = mutableListOf<FragmentStackItem>()
|
||||
|
||||
private val currentFragment: Fragment?
|
||||
get() {
|
||||
supportFragmentManager.executePendingTransactions()
|
||||
return supportFragmentManager.findFragmentById(R.id.main_content)
|
||||
}
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface CustomUserRepositoryInjector {
|
||||
fun settingsRepository(): SettingsRepository
|
||||
}
|
||||
|
||||
private fun collectChange() {
|
||||
val hiltEntryPoint = EntryPointAccessors.fromApplication(
|
||||
this, CustomUserRepositoryInjector::class.java
|
||||
)
|
||||
val newSettings = hiltEntryPoint.settingsRepository().get { theme to dynamicTheme }
|
||||
runBlocking {
|
||||
val theme = newSettings.first()
|
||||
setTheme(
|
||||
resources.configuration.getThemeRes(
|
||||
theme = theme.first, dynamicTheme = theme.second
|
||||
)
|
||||
)
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
newSettings.drop(1).collect { themeAndDynamic ->
|
||||
setTheme(
|
||||
resources.configuration.getThemeRes(
|
||||
theme = themeAndDynamic.first, dynamicTheme = themeAndDynamic.second
|
||||
)
|
||||
)
|
||||
recreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
collectChange()
|
||||
super.onCreate(savedInstanceState)
|
||||
val rootView = FrameLayout(this).apply { id = R.id.main_content }
|
||||
addContentView(
|
||||
rootView, ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
|
||||
requestNotificationPermission(request = notificationPermission::launch)
|
||||
|
||||
supportFragmentManager.addFragmentOnAttachListener { _, _ ->
|
||||
hideKeyboard()
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
cursorOwner = CursorOwner()
|
||||
supportFragmentManager.commit {
|
||||
add(cursorOwner, CursorOwner::class.java.name)
|
||||
}
|
||||
} else {
|
||||
cursorOwner =
|
||||
supportFragmentManager.findFragmentByTag(CursorOwner::class.java.name) as CursorOwner
|
||||
}
|
||||
|
||||
savedInstanceState?.getParcelableArrayList<FragmentStackItem>(STATE_FRAGMENT_STACK)
|
||||
?.let { fragmentStack += it }
|
||||
if (savedInstanceState == null) {
|
||||
replaceFragment(TabsFragment(), null)
|
||||
if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) {
|
||||
handleIntent(intent)
|
||||
}
|
||||
}
|
||||
if (SdkCheck.isR) {
|
||||
window.statusBarColor = resources.getColor(android.R.color.transparent, theme)
|
||||
window.navigationBarColor = resources.getColor(android.R.color.transparent, theme)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
}
|
||||
backHandler()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putParcelableArrayList(STATE_FRAGMENT_STACK, ArrayList(fragmentStack))
|
||||
}
|
||||
|
||||
private fun backHandler() {
|
||||
if (onBackPressedCallback == null) {
|
||||
onBackPressedCallback = object : OnBackPressedCallback(enabled = false) {
|
||||
override fun handleOnBackPressed() {
|
||||
hideKeyboard()
|
||||
popFragment()
|
||||
}
|
||||
}
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
onBackPressedCallback!!,
|
||||
)
|
||||
}
|
||||
onBackPressedCallback?.isEnabled = fragmentStack.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun replaceFragment(fragment: Fragment, open: Boolean?) {
|
||||
if (open != null) {
|
||||
currentFragment?.view?.translationZ =
|
||||
(if (open) Int.MIN_VALUE else Int.MAX_VALUE).toFloat()
|
||||
}
|
||||
supportFragmentManager.commit {
|
||||
if (open != null) {
|
||||
setCustomAnimations(
|
||||
if (open) R.animator.slide_in else 0,
|
||||
if (open) R.animator.slide_in_keep else R.animator.slide_out
|
||||
)
|
||||
}
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.main_content, fragment)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pushFragment(fragment: Fragment) {
|
||||
currentFragment?.let {
|
||||
fragmentStack.add(
|
||||
FragmentStackItem(
|
||||
it::class.java.name,
|
||||
it.arguments,
|
||||
supportFragmentManager.saveFragmentInstanceState(it)
|
||||
)
|
||||
)
|
||||
}
|
||||
replaceFragment(fragment, true)
|
||||
backHandler()
|
||||
}
|
||||
|
||||
private fun popFragment(): Boolean {
|
||||
return fragmentStack.isNotEmpty() && run {
|
||||
val stackItem = fragmentStack.removeAt(fragmentStack.size - 1)
|
||||
val fragment = Class.forName(stackItem.className).newInstance() as Fragment
|
||||
stackItem.arguments?.let(fragment::setArguments)
|
||||
stackItem.savedState?.let(fragment::setInitialSavedState)
|
||||
replaceFragment(fragment, false)
|
||||
backHandler()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideKeyboard() {
|
||||
inputManager?.hideSoftInputFromWindow((currentFocus ?: window.decorView).windowToken, 0)
|
||||
}
|
||||
|
||||
internal fun onToolbarCreated(toolbar: Toolbar) {
|
||||
if (fragmentStack.isNotEmpty()) {
|
||||
toolbar.navigationIcon = toolbar.context.homeAsUp
|
||||
toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
protected fun handleSpecialIntent(specialIntent: SpecialIntent) {
|
||||
when (specialIntent) {
|
||||
is SpecialIntent.Updates -> {
|
||||
if (currentFragment !is TabsFragment) {
|
||||
fragmentStack.clear()
|
||||
replaceFragment(TabsFragment(), true)
|
||||
}
|
||||
val tabsFragment = currentFragment as TabsFragment
|
||||
tabsFragment.selectUpdates()
|
||||
backHandler()
|
||||
}
|
||||
|
||||
is SpecialIntent.Install -> {
|
||||
val packageName = specialIntent.packageName
|
||||
if (!packageName.isNullOrEmpty()) {
|
||||
navigateProduct(packageName)
|
||||
specialIntent.cacheFileName?.also { cacheFile ->
|
||||
val installItem = packageName installFrom cacheFile
|
||||
lifecycleScope.launch { installer install installItem }
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}::class
|
||||
}
|
||||
|
||||
open fun handleIntent(intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
when (val deeplink = intent.deeplinkType) {
|
||||
is DeeplinkType.AppDetail -> {
|
||||
val fragment = currentFragment
|
||||
if (fragment !is AppDetailFragment) {
|
||||
navigateProduct(deeplink.packageName, deeplink.repoAddress)
|
||||
}
|
||||
}
|
||||
|
||||
is DeeplinkType.AddRepository -> {
|
||||
navigateAddRepository(repoAddress = deeplink.address)
|
||||
}
|
||||
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
Intent.ACTION_SHOW_APP_INFO -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val packageName = intent.getStringExtra(Intent.EXTRA_PACKAGE_NAME)
|
||||
|
||||
if (packageName != null && currentFragment !is AppDetailFragment) {
|
||||
navigateProduct(packageName)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun navigateFavourites() = pushFragment(FavouritesFragment())
|
||||
internal fun navigateProduct(packageName: String, repoAddress: String? = null) =
|
||||
pushFragment(AppDetailFragment(packageName, repoAddress))
|
||||
|
||||
internal fun navigateRepositories() = pushFragment(RepositoriesFragment())
|
||||
internal fun navigatePreferences() = pushFragment(SettingsFragment.newInstance())
|
||||
internal fun navigateAddRepository(repoAddress: String? = null) =
|
||||
pushFragment(EditRepositoryFragment(null, repoAddress))
|
||||
|
||||
internal fun navigateRepository(repositoryId: Long) =
|
||||
pushFragment(RepositoryFragment(repositoryId))
|
||||
|
||||
internal fun navigateEditRepository(repositoryId: Long) =
|
||||
pushFragment(EditRepositoryFragment(repositoryId, null))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.looker.droidify.content
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.looker.core.common.extension.Json
|
||||
import com.looker.core.common.extension.parseDictionary
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.droidify.model.ProductPreference
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.utility.serialization.productPreference
|
||||
import com.looker.droidify.utility.serialization.serialize
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.charset.Charset
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object ProductPreferences {
|
||||
private val defaultProductPreference = ProductPreference(false, 0L)
|
||||
private lateinit var preferences: SharedPreferences
|
||||
private val mutableSubject = MutableSharedFlow<Pair<String, Long?>>()
|
||||
private val subject = mutableSubject.asSharedFlow()
|
||||
|
||||
fun init(context: Context, scope: CoroutineScope) {
|
||||
preferences = context.getSharedPreferences("product_preferences", Context.MODE_PRIVATE)
|
||||
Database.LockAdapter.putAll(
|
||||
preferences.all.keys.mapNotNull { packageName ->
|
||||
this[packageName].databaseVersionCode?.let { Pair(packageName, it) }
|
||||
}
|
||||
)
|
||||
scope.launch {
|
||||
subject.collect { (packageName, versionCode) ->
|
||||
if (versionCode != null) {
|
||||
Database.LockAdapter.put(Pair(packageName, versionCode))
|
||||
} else {
|
||||
Database.LockAdapter.delete(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val ProductPreference.databaseVersionCode: Long?
|
||||
get() = when {
|
||||
ignoreUpdates -> 0L
|
||||
ignoreVersionCode > 0L -> ignoreVersionCode
|
||||
else -> null
|
||||
}
|
||||
|
||||
operator fun get(packageName: String): ProductPreference {
|
||||
return if (preferences.contains(packageName)) {
|
||||
try {
|
||||
Json.factory.createParser(preferences.getString(packageName, "{}"))
|
||||
.use { it.parseDictionary { productPreference() } }
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
defaultProductPreference
|
||||
}
|
||||
} else {
|
||||
defaultProductPreference
|
||||
}
|
||||
}
|
||||
|
||||
operator fun set(packageName: String, productPreference: ProductPreference) {
|
||||
val oldProductPreference = this[packageName]
|
||||
preferences.edit().putString(
|
||||
packageName,
|
||||
ByteArrayOutputStream().apply {
|
||||
Json.factory.createGenerator(this)
|
||||
.use { it.writeDictionary(productPreference::serialize) }
|
||||
}.toByteArray().toString(Charset.defaultCharset())
|
||||
).apply()
|
||||
if (oldProductPreference.ignoreUpdates != productPreference.ignoreUpdates ||
|
||||
oldProductPreference.ignoreVersionCode != productPreference.ignoreVersionCode
|
||||
) {
|
||||
mutableSubject.tryEmit(Pair(packageName, productPreference.databaseVersionCode))
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/src/main/kotlin/com/looker/droidify/database/CursorOwner.kt
Normal file
143
app/src/main/kotlin/com/looker/droidify/database/CursorOwner.kt
Normal file
@@ -0,0 +1,143 @@
|
||||
package com.looker.droidify.database
|
||||
|
||||
import android.database.Cursor
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.loader.app.LoaderManager
|
||||
import androidx.loader.content.Loader
|
||||
import com.looker.core.datastore.model.SortOrder
|
||||
import com.looker.droidify.model.ProductItem
|
||||
|
||||
class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
sealed class Request {
|
||||
internal abstract val id: Int
|
||||
|
||||
data class ProductsAvailable(
|
||||
val searchQuery: String,
|
||||
val section: ProductItem.Section,
|
||||
val order: SortOrder
|
||||
) : Request() {
|
||||
override val id: Int
|
||||
get() = 1
|
||||
}
|
||||
|
||||
data class ProductsInstalled(
|
||||
val searchQuery: String,
|
||||
val section: ProductItem.Section,
|
||||
val order: SortOrder
|
||||
) : Request() {
|
||||
override val id: Int
|
||||
get() = 2
|
||||
}
|
||||
|
||||
data class ProductsUpdates(
|
||||
val searchQuery: String,
|
||||
val section: ProductItem.Section,
|
||||
val order: SortOrder
|
||||
) : Request() {
|
||||
override val id: Int
|
||||
get() = 3
|
||||
}
|
||||
|
||||
object Repositories : Request() {
|
||||
override val id: Int
|
||||
get() = 4
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onCursorData(request: Request, cursor: Cursor?)
|
||||
}
|
||||
|
||||
private data class ActiveRequest(
|
||||
val request: Request,
|
||||
val callback: Callback?,
|
||||
val cursor: Cursor?
|
||||
)
|
||||
|
||||
init {
|
||||
retainInstance = true
|
||||
}
|
||||
|
||||
private val activeRequests = mutableMapOf<Int, ActiveRequest>()
|
||||
|
||||
fun attach(callback: Callback, request: Request) {
|
||||
val oldActiveRequest = activeRequests[request.id]
|
||||
if (oldActiveRequest?.callback != null &&
|
||||
oldActiveRequest.callback != callback && oldActiveRequest.cursor != null
|
||||
) {
|
||||
oldActiveRequest.callback.onCursorData(oldActiveRequest.request, null)
|
||||
}
|
||||
val cursor = if (oldActiveRequest?.request == request && oldActiveRequest.cursor != null) {
|
||||
callback.onCursorData(request, oldActiveRequest.cursor)
|
||||
oldActiveRequest.cursor
|
||||
} else {
|
||||
null
|
||||
}
|
||||
activeRequests[request.id] = ActiveRequest(request, callback, cursor)
|
||||
if (cursor == null) {
|
||||
LoaderManager.getInstance(this).restartLoader(request.id, null, this)
|
||||
}
|
||||
}
|
||||
|
||||
fun detach(callback: Callback) {
|
||||
for (id in activeRequests.keys) {
|
||||
val activeRequest = activeRequests[id]!!
|
||||
if (activeRequest.callback == callback) {
|
||||
activeRequests[id] = activeRequest.copy(callback = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
|
||||
val request = activeRequests[id]!!.request
|
||||
return QueryLoader(requireContext()) {
|
||||
when (request) {
|
||||
is Request.ProductsAvailable ->
|
||||
Database.ProductAdapter
|
||||
.query(
|
||||
installed = false,
|
||||
updates = false,
|
||||
searchQuery = request.searchQuery,
|
||||
section = request.section,
|
||||
order = request.order,
|
||||
signal = it
|
||||
)
|
||||
|
||||
is Request.ProductsInstalled ->
|
||||
Database.ProductAdapter
|
||||
.query(
|
||||
installed = true,
|
||||
updates = false,
|
||||
searchQuery = request.searchQuery,
|
||||
section = request.section,
|
||||
order = request.order,
|
||||
signal = it
|
||||
)
|
||||
|
||||
is Request.ProductsUpdates ->
|
||||
Database.ProductAdapter
|
||||
.query(
|
||||
installed = true,
|
||||
updates = true,
|
||||
searchQuery = request.searchQuery,
|
||||
section = request.section,
|
||||
order = request.order,
|
||||
signal = it
|
||||
)
|
||||
|
||||
is Request.Repositories -> Database.RepositoryAdapter.query(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
|
||||
val activeRequest = activeRequests[loader.id]
|
||||
if (activeRequest != null) {
|
||||
activeRequests[loader.id] = activeRequest.copy(cursor = data)
|
||||
activeRequest.callback?.onCursorData(activeRequest.request, data)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Cursor>) = onLoadFinished(loader, null)
|
||||
}
|
||||
968
app/src/main/kotlin/com/looker/droidify/database/Database.kt
Normal file
968
app/src/main/kotlin/com/looker/droidify/database/Database.kt
Normal file
@@ -0,0 +1,968 @@
|
||||
package com.looker.droidify.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import android.os.CancellationSignal
|
||||
import androidx.core.database.sqlite.transaction
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.looker.core.common.extension.Json
|
||||
import com.looker.core.common.extension.asSequence
|
||||
import com.looker.core.common.extension.firstOrNull
|
||||
import com.looker.core.common.extension.parseDictionary
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.core.common.log
|
||||
import com.looker.core.datastore.model.SortOrder
|
||||
import com.looker.droidify.model.InstalledItem
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.utility.serialization.product
|
||||
import com.looker.droidify.utility.serialization.productItem
|
||||
import com.looker.droidify.utility.serialization.repository
|
||||
import com.looker.droidify.utility.serialization.serialize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.collections.component1
|
||||
import kotlin.collections.component2
|
||||
import kotlin.collections.set
|
||||
|
||||
object Database {
|
||||
fun init(context: Context): Boolean {
|
||||
val helper = Helper(context)
|
||||
db = helper.writableDatabase
|
||||
if (helper.created) {
|
||||
for (repository in Repository.defaultRepositories.sortedBy { it.name }) {
|
||||
RepositoryAdapter.put(repository)
|
||||
}
|
||||
}
|
||||
RepositoryAdapter.removeDuplicates()
|
||||
return helper.created || helper.updated
|
||||
}
|
||||
|
||||
private lateinit var db: SQLiteDatabase
|
||||
|
||||
private interface Table {
|
||||
val memory: Boolean
|
||||
val innerName: String
|
||||
val createTable: String
|
||||
val createIndex: String?
|
||||
get() = null
|
||||
|
||||
val databasePrefix: String
|
||||
get() = if (memory) "memory." else ""
|
||||
|
||||
val name: String
|
||||
get() = "$databasePrefix$innerName"
|
||||
|
||||
fun formatCreateTable(name: String): String {
|
||||
return "CREATE TABLE $name (${QueryBuilder.trimQuery(createTable)})"
|
||||
}
|
||||
|
||||
val createIndexPairFormatted: Pair<String, String>?
|
||||
get() = createIndex?.let {
|
||||
Pair(
|
||||
"CREATE INDEX ${innerName}_index ON $innerName ($it)",
|
||||
"CREATE INDEX ${name}_index ON $innerName ($it)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private object Schema {
|
||||
object Repository : Table {
|
||||
const val ROW_ID = "_id"
|
||||
const val ROW_ENABLED = "enabled"
|
||||
const val ROW_DELETED = "deleted"
|
||||
const val ROW_DATA = "data"
|
||||
|
||||
override val memory = false
|
||||
override val innerName = "repository"
|
||||
override val createTable = """
|
||||
$ROW_ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$ROW_ENABLED INTEGER NOT NULL,
|
||||
$ROW_DELETED INTEGER NOT NULL,
|
||||
$ROW_DATA BLOB NOT NULL
|
||||
"""
|
||||
}
|
||||
|
||||
object Product : Table {
|
||||
const val ROW_REPOSITORY_ID = "repository_id"
|
||||
const val ROW_PACKAGE_NAME = "package_name"
|
||||
const val ROW_NAME = "name"
|
||||
const val ROW_SUMMARY = "summary"
|
||||
const val ROW_DESCRIPTION = "description"
|
||||
const val ROW_ADDED = "added"
|
||||
const val ROW_UPDATED = "updated"
|
||||
const val ROW_VERSION_CODE = "version_code"
|
||||
const val ROW_SIGNATURES = "signatures"
|
||||
const val ROW_COMPATIBLE = "compatible"
|
||||
const val ROW_DATA = "data"
|
||||
const val ROW_DATA_ITEM = "data_item"
|
||||
|
||||
override val memory = false
|
||||
override val innerName = "product"
|
||||
override val createTable = """
|
||||
$ROW_REPOSITORY_ID INTEGER NOT NULL,
|
||||
$ROW_PACKAGE_NAME TEXT NOT NULL,
|
||||
$ROW_NAME TEXT NOT NULL,
|
||||
$ROW_SUMMARY TEXT NOT NULL,
|
||||
$ROW_DESCRIPTION TEXT NOT NULL,
|
||||
$ROW_ADDED INTEGER NOT NULL,
|
||||
$ROW_UPDATED INTEGER NOT NULL,
|
||||
$ROW_VERSION_CODE INTEGER NOT NULL,
|
||||
$ROW_SIGNATURES TEXT NOT NULL,
|
||||
$ROW_COMPATIBLE INTEGER NOT NULL,
|
||||
$ROW_DATA BLOB NOT NULL,
|
||||
$ROW_DATA_ITEM BLOB NOT NULL,
|
||||
PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME)
|
||||
"""
|
||||
override val createIndex = ROW_PACKAGE_NAME
|
||||
}
|
||||
|
||||
object Category : Table {
|
||||
const val ROW_REPOSITORY_ID = "repository_id"
|
||||
const val ROW_PACKAGE_NAME = "package_name"
|
||||
const val ROW_NAME = "name"
|
||||
|
||||
override val memory = false
|
||||
override val innerName = "category"
|
||||
override val createTable = """
|
||||
$ROW_REPOSITORY_ID INTEGER NOT NULL,
|
||||
$ROW_PACKAGE_NAME TEXT NOT NULL,
|
||||
$ROW_NAME TEXT NOT NULL,
|
||||
PRIMARY KEY ($ROW_REPOSITORY_ID, $ROW_PACKAGE_NAME, $ROW_NAME)
|
||||
"""
|
||||
override val createIndex = "$ROW_PACKAGE_NAME, $ROW_NAME"
|
||||
}
|
||||
|
||||
object Installed : Table {
|
||||
const val ROW_PACKAGE_NAME = "package_name"
|
||||
const val ROW_VERSION = "version"
|
||||
const val ROW_VERSION_CODE = "version_code"
|
||||
const val ROW_SIGNATURE = "signature"
|
||||
|
||||
override val memory = true
|
||||
override val innerName = "installed"
|
||||
override val createTable = """
|
||||
$ROW_PACKAGE_NAME TEXT PRIMARY KEY,
|
||||
$ROW_VERSION TEXT NOT NULL,
|
||||
$ROW_VERSION_CODE INTEGER NOT NULL,
|
||||
$ROW_SIGNATURE TEXT NOT NULL
|
||||
"""
|
||||
}
|
||||
|
||||
object Lock : Table {
|
||||
const val ROW_PACKAGE_NAME = "package_name"
|
||||
const val ROW_VERSION_CODE = "version_code"
|
||||
|
||||
override val memory = true
|
||||
override val innerName = "lock"
|
||||
override val createTable = """
|
||||
$ROW_PACKAGE_NAME TEXT PRIMARY KEY,
|
||||
$ROW_VERSION_CODE INTEGER NOT NULL
|
||||
"""
|
||||
}
|
||||
|
||||
object Synthetic {
|
||||
const val ROW_CAN_UPDATE = "can_update"
|
||||
const val ROW_MATCH_RANK = "match_rank"
|
||||
}
|
||||
}
|
||||
|
||||
private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 4) {
|
||||
var created = false
|
||||
private set
|
||||
var updated = false
|
||||
private set
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) = Unit
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
|
||||
onVersionChange(db)
|
||||
|
||||
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
|
||||
onVersionChange(db)
|
||||
|
||||
private fun onVersionChange(db: SQLiteDatabase) {
|
||||
handleTables(db, true, Schema.Product, Schema.Category)
|
||||
addRepos(db, Repository.newlyAdded)
|
||||
this.updated = true
|
||||
}
|
||||
|
||||
override fun onOpen(db: SQLiteDatabase) {
|
||||
val create = handleTables(db, false, Schema.Repository)
|
||||
val updated = handleTables(db, create, Schema.Product, Schema.Category)
|
||||
db.execSQL("ATTACH DATABASE ':memory:' AS memory")
|
||||
handleTables(db, false, Schema.Installed, Schema.Lock)
|
||||
handleIndexes(
|
||||
db,
|
||||
Schema.Repository,
|
||||
Schema.Product,
|
||||
Schema.Category,
|
||||
Schema.Installed,
|
||||
Schema.Lock
|
||||
)
|
||||
dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category)
|
||||
this.created = this.created || create
|
||||
this.updated = this.updated || create || updated
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean {
|
||||
val shouldRecreate = recreate || tables.any { table ->
|
||||
val sql = db.query(
|
||||
"${table.databasePrefix}sqlite_master",
|
||||
columns = arrayOf("sql"),
|
||||
selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName))
|
||||
).use { it.firstOrNull()?.getString(0) }.orEmpty()
|
||||
table.formatCreateTable(table.innerName) != sql
|
||||
}
|
||||
return shouldRecreate && run {
|
||||
val shouldVacuum = tables.map {
|
||||
db.execSQL("DROP TABLE IF EXISTS ${it.name}")
|
||||
db.execSQL(it.formatCreateTable(it.name))
|
||||
!it.memory
|
||||
}
|
||||
if (shouldVacuum.any { it } && !db.inTransaction()) {
|
||||
db.execSQL("VACUUM")
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun addRepos(db: SQLiteDatabase, repos: List<Repository>) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
log("Add Repos: $repos", "RepositoryAdapter")
|
||||
}
|
||||
if (repos.isEmpty()) return
|
||||
db.transaction {
|
||||
repos.forEach {
|
||||
RepositoryAdapter.put(it, database = this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) {
|
||||
val shouldVacuum = tables.map { table ->
|
||||
val sqls = db.query(
|
||||
"${table.databasePrefix}sqlite_master",
|
||||
columns = arrayOf("name", "sql"),
|
||||
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName))
|
||||
)
|
||||
.use { cursor ->
|
||||
cursor.asSequence()
|
||||
.mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } }
|
||||
.toList()
|
||||
}
|
||||
.filter { !it.first.startsWith("sqlite_") }
|
||||
val createIndexes = table.createIndexPairFormatted?.let { listOf(it) }.orEmpty()
|
||||
createIndexes.map { it.first } != sqls.map { it.second } && run {
|
||||
for (name in sqls.map { it.first }) {
|
||||
db.execSQL("DROP INDEX IF EXISTS $name")
|
||||
}
|
||||
for (createIndexPair in createIndexes) {
|
||||
db.execSQL(createIndexPair.second)
|
||||
}
|
||||
!table.memory
|
||||
}
|
||||
}
|
||||
if (shouldVacuum.any { it } && !db.inTransaction()) {
|
||||
db.execSQL("VACUUM")
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) {
|
||||
val tables = db.query(
|
||||
"sqlite_master",
|
||||
columns = arrayOf("name"),
|
||||
selection = Pair("type = ?", arrayOf("table"))
|
||||
)
|
||||
.use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() }
|
||||
.filter { !it.startsWith("sqlite_") && !it.startsWith("android_") }
|
||||
.toSet() - neededTables.mapNotNull { if (it.memory) null else it.name }.toSet()
|
||||
if (tables.isNotEmpty()) {
|
||||
for (table in tables) {
|
||||
db.execSQL("DROP TABLE IF EXISTS $table")
|
||||
}
|
||||
if (!db.inTransaction()) {
|
||||
db.execSQL("VACUUM")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Subject {
|
||||
data object Repositories : Subject()
|
||||
data class Repository(val id: Long) : Subject()
|
||||
data object Products : Subject()
|
||||
}
|
||||
|
||||
private val observers = mutableMapOf<Subject, MutableSet<() -> Unit>>()
|
||||
|
||||
private fun dataObservable(subject: Subject): (Boolean, () -> Unit) -> Unit =
|
||||
{ register, observer ->
|
||||
synchronized(observers) {
|
||||
val set = observers[subject] ?: run {
|
||||
val set = mutableSetOf<() -> Unit>()
|
||||
observers[subject] = set
|
||||
set
|
||||
}
|
||||
if (register) {
|
||||
set += observer
|
||||
} else {
|
||||
set -= observer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun flowCollection(subject: Subject): Flow<Unit> = callbackFlow {
|
||||
val callback: () -> Unit = { trySend(Unit) }
|
||||
val dataObservable = dataObservable(subject)
|
||||
dataObservable(true, callback)
|
||||
|
||||
awaitClose { dataObservable(false, callback) }
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun notifyChanged(vararg subjects: Subject) {
|
||||
synchronized(observers) {
|
||||
subjects.asSequence().mapNotNull { observers[it] }.flatten().forEach { it() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.insertOrReplace(
|
||||
replace: Boolean,
|
||||
table: String,
|
||||
contentValues: ContentValues
|
||||
): Long {
|
||||
return if (replace) {
|
||||
replace(table, null, contentValues)
|
||||
} else {
|
||||
insert(
|
||||
table,
|
||||
null,
|
||||
contentValues
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.query(
|
||||
table: String,
|
||||
columns: Array<String>? = null,
|
||||
selection: Pair<String, Array<String>>? = null,
|
||||
orderBy: String? = null,
|
||||
signal: CancellationSignal? = null
|
||||
): Cursor {
|
||||
return query(
|
||||
false,
|
||||
table,
|
||||
columns,
|
||||
selection?.first,
|
||||
selection?.second,
|
||||
null,
|
||||
null,
|
||||
orderBy,
|
||||
null,
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
private fun Cursor.observable(subject: Subject): ObservableCursor {
|
||||
return ObservableCursor(this, dataObservable(subject))
|
||||
}
|
||||
|
||||
fun <T> ByteArray.jsonParse(callback: (JsonParser) -> T): T {
|
||||
return Json.factory.createParser(this).use { it.parseDictionary(callback) }
|
||||
}
|
||||
|
||||
fun jsonGenerate(callback: (JsonGenerator) -> Unit): ByteArray {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
Json.factory.createGenerator(outputStream).use { it.writeDictionary(callback) }
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
|
||||
object RepositoryAdapter {
|
||||
internal fun putWithoutNotification(
|
||||
repository: Repository,
|
||||
shouldReplace: Boolean,
|
||||
database: SQLiteDatabase
|
||||
): Long {
|
||||
return database.insertOrReplace(
|
||||
shouldReplace,
|
||||
Schema.Repository.name,
|
||||
ContentValues().apply {
|
||||
if (shouldReplace) {
|
||||
put(Schema.Repository.ROW_ID, repository.id)
|
||||
}
|
||||
put(Schema.Repository.ROW_ENABLED, if (repository.enabled) 1 else 0)
|
||||
put(Schema.Repository.ROW_DELETED, 0)
|
||||
put(Schema.Repository.ROW_DATA, jsonGenerate(repository::serialize))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun put(repository: Repository, database: SQLiteDatabase = db): Repository {
|
||||
val shouldReplace = repository.id >= 0L
|
||||
val newId = putWithoutNotification(repository, shouldReplace, database)
|
||||
val id = if (shouldReplace) repository.id else newId
|
||||
notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products)
|
||||
return if (newId != repository.id) repository.copy(id = newId) else repository
|
||||
}
|
||||
|
||||
fun removeDuplicates() {
|
||||
db.transaction {
|
||||
val all = getAll()
|
||||
val different = all.distinctBy { it.address }
|
||||
val duplicates = all - different.toSet()
|
||||
duplicates.forEach {
|
||||
markAsDeleted(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getStream(id: Long): Flow<Repository?> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) }
|
||||
.map { get(id) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun get(id: Long): Repository? {
|
||||
return db.query(
|
||||
Schema.Repository.name,
|
||||
selection = Pair(
|
||||
"${Schema.Repository.ROW_ID} = ? AND ${Schema.Repository.ROW_DELETED} == 0",
|
||||
arrayOf(id.toString())
|
||||
)
|
||||
).use { it.firstOrNull()?.let(::transform) }
|
||||
}
|
||||
|
||||
fun getAllStream(): Flow<List<Repository>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) }
|
||||
.map { getAll() }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun getEnabledStream(): Flow<List<Repository>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) }
|
||||
.map { getEnabled() }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
private suspend fun getEnabled(): List<Repository> = withContext(Dispatchers.IO) {
|
||||
db.query(
|
||||
Schema.Repository.name,
|
||||
selection = Pair(
|
||||
"${Schema.Repository.ROW_ENABLED} != 0 AND " +
|
||||
"${Schema.Repository.ROW_DELETED} == 0",
|
||||
emptyArray()
|
||||
),
|
||||
signal = null
|
||||
).use { it.asSequence().map(::transform).toList() }
|
||||
}
|
||||
|
||||
fun getAll(): List<Repository> {
|
||||
return db.query(
|
||||
Schema.Repository.name,
|
||||
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
|
||||
signal = null
|
||||
).use { it.asSequence().map(::transform).toList() }
|
||||
}
|
||||
|
||||
fun getAllRemovedStream(): Flow<Map<Long, Boolean>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) }
|
||||
.map { getAllDisabledDeleted() }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun getAllDisabledDeleted(): Map<Long, Boolean> {
|
||||
return db.query(
|
||||
Schema.Repository.name,
|
||||
columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED),
|
||||
selection = Pair(
|
||||
"${Schema.Repository.ROW_ENABLED} == 0 OR " +
|
||||
"${Schema.Repository.ROW_DELETED} != 0",
|
||||
emptyArray()
|
||||
),
|
||||
signal = null
|
||||
).use { parentCursor ->
|
||||
parentCursor.asSequence().associate {
|
||||
val idIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_ID)
|
||||
val isDeletedIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_DELETED)
|
||||
it.getLong(idIndex) to (it.getInt(isDeletedIndex) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsDeleted(id: Long) {
|
||||
db.update(
|
||||
Schema.Repository.name,
|
||||
ContentValues().apply {
|
||||
put(Schema.Repository.ROW_DELETED, 1)
|
||||
},
|
||||
"${Schema.Repository.ROW_ID} = ?",
|
||||
arrayOf(id.toString())
|
||||
)
|
||||
notifyChanged(Subject.Repositories, Subject.Repository(id), Subject.Products)
|
||||
}
|
||||
|
||||
fun cleanup(removedRepos: Map<Long, Boolean>) {
|
||||
val result = removedRepos.map { (id, isDeleted) ->
|
||||
val idsString = id.toString()
|
||||
val productsCount = db.delete(
|
||||
Schema.Product.name,
|
||||
"${Schema.Product.ROW_REPOSITORY_ID} IN ($idsString)",
|
||||
null
|
||||
)
|
||||
val categoriesCount = db.delete(
|
||||
Schema.Category.name,
|
||||
"${Schema.Category.ROW_REPOSITORY_ID} IN ($idsString)",
|
||||
null
|
||||
)
|
||||
if (isDeleted) {
|
||||
db.delete(
|
||||
Schema.Repository.name,
|
||||
"${Schema.Repository.ROW_ID} IN ($id)",
|
||||
null
|
||||
)
|
||||
}
|
||||
productsCount != 0 || categoriesCount != 0
|
||||
}
|
||||
if (result.any { it }) {
|
||||
notifyChanged(Subject.Products)
|
||||
}
|
||||
}
|
||||
|
||||
fun importRepos(list: List<Repository>) {
|
||||
db.transaction {
|
||||
val currentAddresses = getAll().map { it.address }
|
||||
val newRepos = list
|
||||
.filter { it.address !in currentAddresses }
|
||||
newRepos.forEach { put(it) }
|
||||
removeDuplicates()
|
||||
}
|
||||
}
|
||||
|
||||
fun query(signal: CancellationSignal?): Cursor {
|
||||
return db.query(
|
||||
Schema.Repository.name,
|
||||
selection = Pair("${Schema.Repository.ROW_DELETED} == 0", emptyArray()),
|
||||
orderBy = "${Schema.Repository.ROW_ENABLED} DESC",
|
||||
signal = signal
|
||||
).observable(Subject.Repositories)
|
||||
}
|
||||
|
||||
fun transform(cursor: Cursor): Repository {
|
||||
return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_DATA))
|
||||
.jsonParse {
|
||||
it.repository().apply {
|
||||
this.id =
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Repository.ROW_ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ProductAdapter {
|
||||
|
||||
fun getStream(packageName: String): Flow<List<Product>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
||||
.map { get(packageName, null) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
suspend fun getUpdates(): List<ProductItem> = withContext(Dispatchers.IO) {
|
||||
query(
|
||||
installed = true,
|
||||
updates = true,
|
||||
searchQuery = "",
|
||||
section = ProductItem.Section.All,
|
||||
order = SortOrder.NAME,
|
||||
signal = null
|
||||
).use {
|
||||
it.asSequence()
|
||||
.map(ProductAdapter::transformItem)
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun getUpdatesStream(): Flow<List<ProductItem>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
||||
// Crashes due to immediate retrieval of data?
|
||||
.onEach { delay(50) }
|
||||
.map { getUpdates() }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun get(packageName: String, signal: CancellationSignal?): List<Product> {
|
||||
return db.query(
|
||||
Schema.Product.name,
|
||||
columns = arrayOf(
|
||||
Schema.Product.ROW_REPOSITORY_ID,
|
||||
Schema.Product.ROW_DESCRIPTION,
|
||||
Schema.Product.ROW_DATA
|
||||
),
|
||||
selection = Pair("${Schema.Product.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
|
||||
signal = signal
|
||||
).use { it.asSequence().map(::transform).toList() }
|
||||
}
|
||||
|
||||
fun getCountStream(repositoryId: Long): Flow<Int> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
||||
.map { getCount(repositoryId) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun getCount(repositoryId: Long): Int {
|
||||
return db.query(
|
||||
Schema.Product.name,
|
||||
columns = arrayOf("COUNT (*)"),
|
||||
selection = Pair(
|
||||
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
|
||||
arrayOf(repositoryId.toString())
|
||||
)
|
||||
).use { it.firstOrNull()?.getInt(0) ?: 0 }
|
||||
}
|
||||
|
||||
fun query(
|
||||
installed: Boolean,
|
||||
updates: Boolean,
|
||||
searchQuery: String,
|
||||
section: ProductItem.Section,
|
||||
order: SortOrder,
|
||||
signal: CancellationSignal?
|
||||
): Cursor {
|
||||
val builder = QueryBuilder()
|
||||
|
||||
val signatureMatches = """installed.${Schema.Installed.ROW_SIGNATURE} IS NOT NULL AND
|
||||
product.${Schema.Product.ROW_SIGNATURES} LIKE ('%.' || installed.${Schema.Installed.ROW_SIGNATURE} || '.%') AND
|
||||
product.${Schema.Product.ROW_SIGNATURES} != ''"""
|
||||
|
||||
builder += """SELECT product.rowid AS _id, product.${Schema.Product.ROW_REPOSITORY_ID},
|
||||
product.${Schema.Product.ROW_PACKAGE_NAME}, product.${Schema.Product.ROW_NAME},
|
||||
product.${Schema.Product.ROW_SUMMARY}, installed.${Schema.Installed.ROW_VERSION},
|
||||
(COALESCE(lock.${Schema.Lock.ROW_VERSION_CODE}, -1) NOT IN (0, product.${Schema.Product.ROW_VERSION_CODE}) AND
|
||||
product.${Schema.Product.ROW_COMPATIBLE} != 0 AND product.${Schema.Product.ROW_VERSION_CODE} >
|
||||
COALESCE(installed.${Schema.Installed.ROW_VERSION_CODE}, 0xffffffff) AND $signatureMatches)
|
||||
AS ${Schema.Synthetic.ROW_CAN_UPDATE}, product.${Schema.Product.ROW_COMPATIBLE},
|
||||
product.${Schema.Product.ROW_DATA_ITEM},"""
|
||||
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
builder += """(((product.${Schema.Product.ROW_NAME} LIKE ? OR
|
||||
product.${Schema.Product.ROW_SUMMARY} LIKE ?) * 7) |
|
||||
((product.${Schema.Product.ROW_PACKAGE_NAME} LIKE ?) * 3) |
|
||||
(product.${Schema.Product.ROW_DESCRIPTION} LIKE ?)) AS ${Schema.Synthetic.ROW_MATCH_RANK},"""
|
||||
builder %= List(4) { "%$searchQuery%" }
|
||||
} else {
|
||||
builder += "0 AS ${Schema.Synthetic.ROW_MATCH_RANK},"
|
||||
}
|
||||
|
||||
builder += """MAX((product.${Schema.Product.ROW_COMPATIBLE} AND
|
||||
(installed.${Schema.Installed.ROW_SIGNATURE} IS NULL OR $signatureMatches)) ||
|
||||
PRINTF('%016X', product.${Schema.Product.ROW_VERSION_CODE})) FROM ${Schema.Product.name} AS product"""
|
||||
builder += """JOIN ${Schema.Repository.name} AS repository
|
||||
ON product.${Schema.Product.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID}"""
|
||||
builder += """LEFT JOIN ${Schema.Lock.name} AS lock
|
||||
ON product.${Schema.Product.ROW_PACKAGE_NAME} = lock.${Schema.Lock.ROW_PACKAGE_NAME}"""
|
||||
|
||||
if (!installed && !updates) {
|
||||
builder += "LEFT"
|
||||
}
|
||||
builder += """JOIN ${Schema.Installed.name} AS installed
|
||||
ON product.${Schema.Product.ROW_PACKAGE_NAME} = installed.${Schema.Installed.ROW_PACKAGE_NAME}"""
|
||||
|
||||
if (section is ProductItem.Section.Category) {
|
||||
builder += """JOIN ${Schema.Category.name} AS category
|
||||
ON product.${Schema.Product.ROW_PACKAGE_NAME} = category.${Schema.Product.ROW_PACKAGE_NAME}"""
|
||||
}
|
||||
|
||||
builder += """WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND
|
||||
repository.${Schema.Repository.ROW_DELETED} == 0"""
|
||||
|
||||
if (section is ProductItem.Section.Category) {
|
||||
builder += "AND category.${Schema.Category.ROW_NAME} = ?"
|
||||
builder %= section.name
|
||||
} else if (section is ProductItem.Section.Repository) {
|
||||
builder += "AND product.${Schema.Product.ROW_REPOSITORY_ID} = ?"
|
||||
builder %= section.id.toString()
|
||||
}
|
||||
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
builder += """AND ${Schema.Synthetic.ROW_MATCH_RANK} > 0"""
|
||||
}
|
||||
|
||||
builder += "GROUP BY product.${Schema.Product.ROW_PACKAGE_NAME} HAVING 1"
|
||||
|
||||
if (updates) {
|
||||
builder += "AND ${Schema.Synthetic.ROW_CAN_UPDATE}"
|
||||
}
|
||||
builder += "ORDER BY"
|
||||
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
builder += """${Schema.Synthetic.ROW_MATCH_RANK} DESC,"""
|
||||
}
|
||||
|
||||
when (order) {
|
||||
SortOrder.UPDATED -> builder += "product.${Schema.Product.ROW_UPDATED} DESC,"
|
||||
SortOrder.ADDED -> builder += "product.${Schema.Product.ROW_ADDED} DESC,"
|
||||
SortOrder.NAME -> Unit
|
||||
}::class
|
||||
builder += "product.${Schema.Product.ROW_NAME} COLLATE LOCALIZED ASC"
|
||||
|
||||
return builder.query(db, signal).observable(Subject.Products)
|
||||
}
|
||||
|
||||
private fun transform(cursor: Cursor): Product {
|
||||
return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA))
|
||||
.jsonParse {
|
||||
it.product().apply {
|
||||
this.repositoryId = cursor
|
||||
.getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID))
|
||||
this.description = cursor
|
||||
.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DESCRIPTION))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun transformItem(cursor: Cursor): ProductItem {
|
||||
return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA_ITEM))
|
||||
.jsonParse {
|
||||
it.productItem().apply {
|
||||
this.repositoryId = cursor
|
||||
.getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID))
|
||||
this.packageName = cursor
|
||||
.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME))
|
||||
this.name = cursor
|
||||
.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_NAME))
|
||||
this.summary = cursor
|
||||
.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_SUMMARY))
|
||||
this.installedVersion = cursor
|
||||
.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION))
|
||||
.orEmpty()
|
||||
this.compatible = cursor
|
||||
.getInt(cursor.getColumnIndexOrThrow(Schema.Product.ROW_COMPATIBLE)) != 0
|
||||
this.canUpdate = cursor
|
||||
.getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_CAN_UPDATE)) != 0
|
||||
this.matchRank = cursor
|
||||
.getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_MATCH_RANK))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object CategoryAdapter {
|
||||
|
||||
fun getAllStream(): Flow<Set<String>> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
||||
.map { getAll() }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
private suspend fun getAll(): Set<String> = withContext(Dispatchers.IO) {
|
||||
val builder = QueryBuilder()
|
||||
|
||||
builder += """SELECT DISTINCT category.${Schema.Category.ROW_NAME}
|
||||
FROM ${Schema.Category.name} AS category
|
||||
JOIN ${Schema.Repository.name} AS repository
|
||||
ON category.${Schema.Category.ROW_REPOSITORY_ID} = repository.${Schema.Repository.ROW_ID}
|
||||
WHERE repository.${Schema.Repository.ROW_ENABLED} != 0 AND
|
||||
repository.${Schema.Repository.ROW_DELETED} == 0"""
|
||||
|
||||
builder.query(db, null).use { cursor ->
|
||||
cursor.asSequence().map {
|
||||
it.getString(it.getColumnIndexOrThrow(Schema.Category.ROW_NAME))
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object InstalledAdapter {
|
||||
|
||||
fun getStream(packageName: String): Flow<InstalledItem?> = flowOf(Unit)
|
||||
.onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) }
|
||||
.map { get(packageName, null) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun get(packageName: String, signal: CancellationSignal?): InstalledItem? {
|
||||
return db.query(
|
||||
Schema.Installed.name,
|
||||
columns = arrayOf(
|
||||
Schema.Installed.ROW_PACKAGE_NAME,
|
||||
Schema.Installed.ROW_VERSION,
|
||||
Schema.Installed.ROW_VERSION_CODE,
|
||||
Schema.Installed.ROW_SIGNATURE
|
||||
),
|
||||
selection = Pair("${Schema.Installed.ROW_PACKAGE_NAME} = ?", arrayOf(packageName)),
|
||||
signal = signal
|
||||
).use { it.firstOrNull()?.let(::transform) }
|
||||
}
|
||||
|
||||
private fun put(installedItem: InstalledItem, notify: Boolean) {
|
||||
db.insertOrReplace(
|
||||
true,
|
||||
Schema.Installed.name,
|
||||
ContentValues().apply {
|
||||
put(Schema.Installed.ROW_PACKAGE_NAME, installedItem.packageName)
|
||||
put(Schema.Installed.ROW_VERSION, installedItem.version)
|
||||
put(Schema.Installed.ROW_VERSION_CODE, installedItem.versionCode)
|
||||
put(Schema.Installed.ROW_SIGNATURE, installedItem.signature)
|
||||
}
|
||||
)
|
||||
if (notify) {
|
||||
notifyChanged(Subject.Products)
|
||||
}
|
||||
}
|
||||
|
||||
fun put(installedItem: InstalledItem) = put(installedItem, true)
|
||||
|
||||
fun putAll(installedItems: List<InstalledItem>) {
|
||||
db.transaction {
|
||||
db.delete(Schema.Installed.name, null, null)
|
||||
installedItems.forEach { put(it, false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(packageName: String) {
|
||||
val count = db.delete(
|
||||
Schema.Installed.name,
|
||||
"${Schema.Installed.ROW_PACKAGE_NAME} = ?",
|
||||
arrayOf(packageName)
|
||||
)
|
||||
if (count > 0) {
|
||||
notifyChanged(Subject.Products)
|
||||
}
|
||||
}
|
||||
|
||||
private fun transform(cursor: Cursor): InstalledItem {
|
||||
return InstalledItem(
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_PACKAGE_NAME)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION_CODE)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_SIGNATURE))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object LockAdapter {
|
||||
private fun put(lock: Pair<String, Long>, notify: Boolean) {
|
||||
db.insertOrReplace(
|
||||
true,
|
||||
Schema.Lock.name,
|
||||
ContentValues().apply {
|
||||
put(Schema.Lock.ROW_PACKAGE_NAME, lock.first)
|
||||
put(Schema.Lock.ROW_VERSION_CODE, lock.second)
|
||||
}
|
||||
)
|
||||
if (notify) {
|
||||
notifyChanged(Subject.Products)
|
||||
}
|
||||
}
|
||||
|
||||
fun put(lock: Pair<String, Long>) = put(lock, true)
|
||||
|
||||
fun putAll(locks: List<Pair<String, Long>>) {
|
||||
db.transaction {
|
||||
db.delete(Schema.Lock.name, null, null)
|
||||
locks.forEach { put(it, false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(packageName: String) {
|
||||
db.delete(Schema.Lock.name, "${Schema.Lock.ROW_PACKAGE_NAME} = ?", arrayOf(packageName))
|
||||
notifyChanged(Subject.Products)
|
||||
}
|
||||
}
|
||||
|
||||
object UpdaterAdapter {
|
||||
private val Table.temporaryName: String
|
||||
get() = "${name}_temporary"
|
||||
|
||||
fun createTemporaryTable() {
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
|
||||
db.execSQL(Schema.Product.formatCreateTable(Schema.Product.temporaryName))
|
||||
db.execSQL(Schema.Category.formatCreateTable(Schema.Category.temporaryName))
|
||||
}
|
||||
|
||||
fun putTemporary(products: List<Product>) {
|
||||
db.transaction {
|
||||
for (product in products) {
|
||||
// Format signatures like ".signature1.signature2." for easier select
|
||||
val signatures = product.signatures.joinToString { ".$it" }
|
||||
.let { if (it.isNotEmpty()) "$it." else "" }
|
||||
db.insertOrReplace(
|
||||
true,
|
||||
Schema.Product.temporaryName,
|
||||
ContentValues().apply {
|
||||
put(Schema.Product.ROW_REPOSITORY_ID, product.repositoryId)
|
||||
put(Schema.Product.ROW_PACKAGE_NAME, product.packageName)
|
||||
put(Schema.Product.ROW_NAME, product.name)
|
||||
put(Schema.Product.ROW_SUMMARY, product.summary)
|
||||
put(Schema.Product.ROW_DESCRIPTION, product.description)
|
||||
put(Schema.Product.ROW_ADDED, product.added)
|
||||
put(Schema.Product.ROW_UPDATED, product.updated)
|
||||
put(Schema.Product.ROW_VERSION_CODE, product.versionCode)
|
||||
put(Schema.Product.ROW_SIGNATURES, signatures)
|
||||
put(Schema.Product.ROW_COMPATIBLE, if (product.compatible) 1 else 0)
|
||||
put(Schema.Product.ROW_DATA, jsonGenerate(product::serialize))
|
||||
put(
|
||||
Schema.Product.ROW_DATA_ITEM,
|
||||
jsonGenerate(product.item()::serialize)
|
||||
)
|
||||
}
|
||||
)
|
||||
for (category in product.categories) {
|
||||
db.insertOrReplace(
|
||||
true,
|
||||
Schema.Category.temporaryName,
|
||||
ContentValues().apply {
|
||||
put(Schema.Category.ROW_REPOSITORY_ID, product.repositoryId)
|
||||
put(Schema.Category.ROW_PACKAGE_NAME, product.packageName)
|
||||
put(Schema.Category.ROW_NAME, category)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun finishTemporary(repository: Repository, success: Boolean) {
|
||||
if (success) {
|
||||
db.transaction {
|
||||
db.delete(
|
||||
Schema.Product.name,
|
||||
"${Schema.Product.ROW_REPOSITORY_ID} = ?",
|
||||
arrayOf(repository.id.toString())
|
||||
)
|
||||
db.delete(
|
||||
Schema.Category.name,
|
||||
"${Schema.Category.ROW_REPOSITORY_ID} = ?",
|
||||
arrayOf(repository.id.toString())
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO ${Schema.Product.name} SELECT * " +
|
||||
"FROM ${Schema.Product.temporaryName}"
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO ${Schema.Category.name} SELECT * " +
|
||||
"FROM ${Schema.Category.temporaryName}"
|
||||
)
|
||||
RepositoryAdapter.putWithoutNotification(repository, true, db)
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
|
||||
}
|
||||
notifyChanged(
|
||||
Subject.Repositories,
|
||||
Subject.Repository(repository.id),
|
||||
Subject.Products
|
||||
)
|
||||
} else {
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}")
|
||||
db.execSQL("DROP TABLE IF EXISTS ${Schema.Category.temporaryName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.looker.droidify.database
|
||||
|
||||
import android.database.ContentObservable
|
||||
import android.database.ContentObserver
|
||||
import android.database.Cursor
|
||||
import android.database.CursorWrapper
|
||||
|
||||
class ObservableCursor(
|
||||
cursor: Cursor,
|
||||
private val observable: (
|
||||
register: Boolean,
|
||||
observer: () -> Unit
|
||||
) -> Unit
|
||||
) : CursorWrapper(cursor) {
|
||||
private var registered = false
|
||||
private val contentObservable = ContentObservable()
|
||||
|
||||
private val onChange: () -> Unit = {
|
||||
contentObservable.dispatchChange(false, null)
|
||||
}
|
||||
|
||||
init {
|
||||
observable(true, onChange)
|
||||
registered = true
|
||||
}
|
||||
|
||||
override fun registerContentObserver(observer: ContentObserver) {
|
||||
super.registerContentObserver(observer)
|
||||
contentObservable.registerObserver(observer)
|
||||
}
|
||||
|
||||
override fun unregisterContentObserver(observer: ContentObserver) {
|
||||
super.unregisterContentObserver(observer)
|
||||
contentObservable.unregisterObserver(observer)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
@Suppress("DEPRECATION")
|
||||
override fun requery(): Boolean {
|
||||
if (!registered) {
|
||||
observable(true, onChange)
|
||||
registered = true
|
||||
}
|
||||
return super.requery()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
@Suppress("DEPRECATION")
|
||||
override fun deactivate() {
|
||||
super.deactivate()
|
||||
deactivateOrClose()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
super.close()
|
||||
contentObservable.unregisterAll()
|
||||
deactivateOrClose()
|
||||
}
|
||||
|
||||
private fun deactivateOrClose() {
|
||||
observable(false, onChange)
|
||||
registered = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.looker.droidify.database
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.CancellationSignal
|
||||
import com.looker.core.common.extension.asSequence
|
||||
import com.looker.core.common.log
|
||||
import com.looker.droidify.BuildConfig
|
||||
|
||||
class QueryBuilder {
|
||||
companion object {
|
||||
fun trimQuery(query: String): String {
|
||||
return query.lines().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
.joinToString(separator = " ")
|
||||
}
|
||||
}
|
||||
|
||||
private val builder = StringBuilder()
|
||||
private val arguments = mutableListOf<String>()
|
||||
|
||||
operator fun plusAssign(query: String) {
|
||||
if (builder.isNotEmpty()) {
|
||||
builder.append(" ")
|
||||
}
|
||||
builder.append(trimQuery(query))
|
||||
}
|
||||
|
||||
operator fun remAssign(argument: String) {
|
||||
this.arguments += argument
|
||||
}
|
||||
|
||||
operator fun remAssign(arguments: List<String>) {
|
||||
this.arguments += arguments
|
||||
}
|
||||
|
||||
fun query(db: SQLiteDatabase, signal: CancellationSignal?): Cursor {
|
||||
val query = builder.toString()
|
||||
val arguments = arguments.toTypedArray()
|
||||
if (BuildConfig.DEBUG) {
|
||||
synchronized(QueryBuilder::class.java) {
|
||||
log(query)
|
||||
db.rawQuery("EXPLAIN QUERY PLAN $query", arguments).use {
|
||||
it.asSequence()
|
||||
.forEach { log(":: ${it.getString(it.getColumnIndex("detail"))}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
return db.rawQuery(query, arguments, signal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.looker.droidify.database
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import androidx.loader.content.AsyncTaskLoader
|
||||
|
||||
class QueryLoader(context: Context, private val query: (CancellationSignal) -> Cursor?) :
|
||||
AsyncTaskLoader<Cursor>(context) {
|
||||
private val observer = ForceLoadContentObserver()
|
||||
private var cancellationSignal: CancellationSignal? = null
|
||||
private var cursor: Cursor? = null
|
||||
|
||||
override fun loadInBackground(): Cursor? {
|
||||
val cancellationSignal = synchronized(this) {
|
||||
if (isLoadInBackgroundCanceled) {
|
||||
throw OperationCanceledException()
|
||||
}
|
||||
val cancellationSignal = CancellationSignal()
|
||||
this.cancellationSignal = cancellationSignal
|
||||
cancellationSignal
|
||||
}
|
||||
try {
|
||||
val cursor = query(cancellationSignal)
|
||||
if (cursor != null) {
|
||||
try {
|
||||
cursor.count // Ensure the cursor window is filled
|
||||
cursor.registerContentObserver(observer)
|
||||
} catch (e: Exception) {
|
||||
cursor.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return cursor
|
||||
} finally {
|
||||
synchronized(this) {
|
||||
this.cancellationSignal = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelLoadInBackground() {
|
||||
super.cancelLoadInBackground()
|
||||
|
||||
synchronized(this) {
|
||||
cancellationSignal?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun deliverResult(data: Cursor?) {
|
||||
if (isReset) {
|
||||
data?.close()
|
||||
} else {
|
||||
val oldCursor = cursor
|
||||
cursor = data
|
||||
if (isStarted) {
|
||||
super.deliverResult(data)
|
||||
}
|
||||
if (oldCursor != data) {
|
||||
oldCursor.closeIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartLoading() {
|
||||
cursor?.let(this::deliverResult)
|
||||
if (takeContentChanged() || cursor == null) {
|
||||
forceLoad()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopLoading() {
|
||||
cancelLoad()
|
||||
}
|
||||
|
||||
override fun onCanceled(data: Cursor?) {
|
||||
data.closeIfNeeded()
|
||||
}
|
||||
|
||||
override fun onReset() {
|
||||
super.onReset()
|
||||
|
||||
stopLoading()
|
||||
cursor.closeIfNeeded()
|
||||
cursor = null
|
||||
}
|
||||
|
||||
private fun Cursor?.closeIfNeeded() {
|
||||
if (this != null && !isClosed) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.looker.droidify.database
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.looker.core.common.Exporter
|
||||
import com.looker.core.common.extension.Json
|
||||
import com.looker.core.common.extension.forEach
|
||||
import com.looker.core.common.extension.forEachKey
|
||||
import com.looker.core.common.extension.parseDictionary
|
||||
import com.looker.core.common.extension.writeArray
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.core.di.ApplicationScope
|
||||
import com.looker.core.di.IoDispatcher
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.utility.serialization.repository
|
||||
import com.looker.droidify.utility.serialization.serialize
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Singleton
|
||||
class RepositoryExporter @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
|
||||
) : Exporter<List<Repository>> {
|
||||
override suspend fun export(item: List<Repository>, target: Uri) {
|
||||
scope.launch(ioDispatcher) {
|
||||
val stream = context.contentResolver.openOutputStream(target)
|
||||
Json.factory.createGenerator(stream).use { generator ->
|
||||
generator.writeDictionary {
|
||||
writeArray("repositories") {
|
||||
item.map {
|
||||
it.copy(
|
||||
id = -1,
|
||||
mirrors = if (it.enabled) it.mirrors else emptyList(),
|
||||
lastModified = "",
|
||||
entityTag = ""
|
||||
)
|
||||
}.forEach { repo ->
|
||||
writeDictionary {
|
||||
repo.serialize(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun import(target: Uri): List<Repository> = withContext(ioDispatcher) {
|
||||
val list = mutableListOf<Repository>()
|
||||
val stream = context.contentResolver.openInputStream(target)
|
||||
Json.factory.createParser(stream).use { generator ->
|
||||
generator?.parseDictionary {
|
||||
forEachKey {
|
||||
if (it.array("repositories")) {
|
||||
forEach(JsonToken.START_OBJECT) {
|
||||
val repo = repository()
|
||||
list.add(repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.looker.droidify.graphics
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
open class DrawableWrapper(val drawable: Drawable) : Drawable() {
|
||||
init {
|
||||
drawable.callback = object : Callback {
|
||||
override fun invalidateDrawable(who: Drawable) {
|
||||
callback?.invalidateDrawable(who)
|
||||
}
|
||||
|
||||
override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) {
|
||||
callback?.scheduleDrawable(who, what, `when`)
|
||||
}
|
||||
|
||||
override fun unscheduleDrawable(who: Drawable, what: Runnable) {
|
||||
callback?.unscheduleDrawable(who, what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBoundsChange(bounds: Rect) {
|
||||
drawable.bounds = bounds
|
||||
}
|
||||
|
||||
override fun getIntrinsicWidth(): Int = drawable.intrinsicWidth
|
||||
override fun getIntrinsicHeight(): Int = drawable.intrinsicHeight
|
||||
override fun getMinimumWidth(): Int = drawable.minimumWidth
|
||||
override fun getMinimumHeight(): Int = drawable.minimumHeight
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
drawable.draw(canvas)
|
||||
}
|
||||
|
||||
override fun getAlpha(): Int {
|
||||
return drawable.alpha
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
drawable.alpha = alpha
|
||||
}
|
||||
|
||||
override fun getColorFilter(): ColorFilter? {
|
||||
return drawable.colorFilter
|
||||
}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||
drawable.colorFilter = colorFilter
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
@Suppress("DEPRECATION")
|
||||
override fun getOpacity(): Int = drawable.opacity
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.looker.droidify.graphics
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class PaddingDrawable(
|
||||
drawable: Drawable,
|
||||
private val horizontalFactor: Float,
|
||||
private val aspectRatio: Float = 16f / 9f
|
||||
) : DrawableWrapper(drawable) {
|
||||
override fun getIntrinsicWidth(): Int =
|
||||
(horizontalFactor * super.getIntrinsicWidth()).roundToInt()
|
||||
|
||||
override fun getIntrinsicHeight(): Int =
|
||||
((horizontalFactor * aspectRatio) * super.getIntrinsicHeight()).roundToInt()
|
||||
|
||||
override fun onBoundsChange(bounds: Rect) {
|
||||
val width = (bounds.width() / horizontalFactor).roundToInt()
|
||||
val height = (bounds.height() / (horizontalFactor * aspectRatio)).roundToInt()
|
||||
val left = (bounds.width() - width) / 2
|
||||
val top = (bounds.height() - height) / 2
|
||||
drawable.setBounds(
|
||||
bounds.left + left,
|
||||
bounds.top + top,
|
||||
bounds.left + left + width,
|
||||
bounds.top + top + height
|
||||
)
|
||||
}
|
||||
}
|
||||
115
app/src/main/kotlin/com/looker/droidify/index/IndexMerger.kt
Normal file
115
app/src/main/kotlin/com/looker/droidify/index/IndexMerger.kt
Normal file
@@ -0,0 +1,115 @@
|
||||
package com.looker.droidify.index
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.looker.core.common.extension.Json
|
||||
import com.looker.core.common.extension.asSequence
|
||||
import com.looker.core.common.extension.collectNotNull
|
||||
import com.looker.core.common.extension.execWithResult
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.utility.serialization.product
|
||||
import com.looker.droidify.utility.serialization.release
|
||||
import com.looker.droidify.utility.serialization.serialize
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
|
||||
class IndexMerger(file: File) : Closeable {
|
||||
private val db = SQLiteDatabase.openOrCreateDatabase(file, null)
|
||||
|
||||
init {
|
||||
db.execWithResult("PRAGMA synchronous = OFF")
|
||||
db.execWithResult("PRAGMA journal_mode = OFF")
|
||||
db.execSQL(
|
||||
"CREATE TABLE product (" +
|
||||
"package_name TEXT PRIMARY KEY," +
|
||||
"description TEXT NOT NULL, " +
|
||||
"data BLOB NOT NULL)"
|
||||
)
|
||||
db.execSQL("CREATE TABLE releases (package_name TEXT PRIMARY KEY, data BLOB NOT NULL)")
|
||||
db.beginTransaction()
|
||||
}
|
||||
|
||||
fun addProducts(products: List<Product>) {
|
||||
for (product in products) {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
Json.factory.createGenerator(outputStream)
|
||||
.use { it.writeDictionary(product::serialize) }
|
||||
db.insert(
|
||||
"product",
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put("package_name", product.packageName)
|
||||
put("description", product.description)
|
||||
put("data", outputStream.toByteArray())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addReleases(pairs: List<Pair<String, List<Release>>>) {
|
||||
for (pair in pairs) {
|
||||
val (packageName, releases) = pair
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
Json.factory.createGenerator(outputStream).use {
|
||||
it.writeStartArray()
|
||||
for (release in releases) {
|
||||
it.writeDictionary(release::serialize)
|
||||
}
|
||||
it.writeEndArray()
|
||||
}
|
||||
db.insert(
|
||||
"releases",
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put("package_name", packageName)
|
||||
put("data", outputStream.toByteArray())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeTransaction() {
|
||||
if (db.inTransaction()) {
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
fun forEach(repositoryId: Long, windowSize: Int, callback: (List<Product>, Int) -> Unit) {
|
||||
closeTransaction()
|
||||
db.rawQuery(
|
||||
"""SELECT product.description, product.data AS pd, releases.data AS rd FROM product
|
||||
LEFT JOIN releases ON product.package_name = releases.package_name""",
|
||||
null
|
||||
)?.use { cursor ->
|
||||
cursor.asSequence().map { currentCursor ->
|
||||
val description = currentCursor.getString(0)
|
||||
val product = Json.factory.createParser(currentCursor.getBlob(1)).use {
|
||||
it.nextToken()
|
||||
it.product().apply {
|
||||
this.repositoryId = repositoryId
|
||||
this.description = description
|
||||
}
|
||||
}
|
||||
val releases = currentCursor.getBlob(2)?.let { bytes ->
|
||||
Json.factory.createParser(bytes).use {
|
||||
it.nextToken()
|
||||
it.collectNotNull(
|
||||
JsonToken.START_OBJECT
|
||||
) { release() }
|
||||
}
|
||||
}.orEmpty()
|
||||
product.copy(releases = releases)
|
||||
}.windowed(windowSize, windowSize, true)
|
||||
.forEach { products -> callback(products, cursor.count) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
db.use { closeTransaction() }
|
||||
}
|
||||
}
|
||||
489
app/src/main/kotlin/com/looker/droidify/index/IndexV1Parser.kt
Normal file
489
app/src/main/kotlin/com/looker/droidify/index/IndexV1Parser.kt
Normal file
@@ -0,0 +1,489 @@
|
||||
package com.looker.droidify.index
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.core.os.ConfigurationCompat.getLocales
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.extension.Json
|
||||
import com.looker.core.common.extension.collectDistinctNotEmptyStrings
|
||||
import com.looker.core.common.extension.collectNotNull
|
||||
import com.looker.core.common.extension.forEach
|
||||
import com.looker.core.common.extension.forEachKey
|
||||
import com.looker.core.common.extension.illegal
|
||||
import com.looker.core.common.nullIfEmpty
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Release
|
||||
import java.io.InputStream
|
||||
|
||||
object IndexV1Parser {
|
||||
interface Callback {
|
||||
fun onRepository(
|
||||
mirrors: List<String>,
|
||||
name: String,
|
||||
description: String,
|
||||
version: Int,
|
||||
timestamp: Long
|
||||
)
|
||||
|
||||
fun onProduct(product: Product)
|
||||
fun onReleases(packageName: String, releases: List<Release>)
|
||||
}
|
||||
|
||||
private class Screenshots(
|
||||
val phone: List<String>,
|
||||
val smallTablet: List<String>,
|
||||
val largeTablet: List<String>
|
||||
)
|
||||
|
||||
private class Localized(
|
||||
val name: String,
|
||||
val summary: String,
|
||||
val description: String,
|
||||
val whatsNew: String,
|
||||
val metadataIcon: String,
|
||||
val screenshots: Screenshots?
|
||||
)
|
||||
|
||||
private fun <T> Map<String, Localized>.getAndCall(
|
||||
key: String,
|
||||
callback: (String, Localized) -> T?
|
||||
): T? {
|
||||
return this[key]?.let { callback(key, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the best localization for the given [localeList]
|
||||
* from collections.
|
||||
*/
|
||||
private fun <T> Map<String, T>?.getBestLocale(localeList: LocaleListCompat): T? {
|
||||
if (isNullOrEmpty()) return null
|
||||
val firstMatch = localeList.getFirstMatch(keys.toTypedArray()) ?: return null
|
||||
val tag = firstMatch.toLanguageTag()
|
||||
// try first matched tag first (usually has region tag, e.g. de-DE)
|
||||
return get(tag) ?: run {
|
||||
// split away stuff like script and try language and region only
|
||||
val langCountryTag = "${firstMatch.language}-${firstMatch.country}"
|
||||
getOrStartsWith(langCountryTag) ?: run {
|
||||
// split away region tag and try language only
|
||||
val langTag = firstMatch.language
|
||||
// try language, then English and then just take the first of the list
|
||||
getOrStartsWith(langTag) ?: get("en-US") ?: get("en") ?: values.first()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value from the map with the given key or if that key is not contained in the map,
|
||||
* tries the first map key that starts with the given key.
|
||||
* If nothing matches, null is returned.
|
||||
*
|
||||
* This is useful when looking for a language tag like `fr_CH` and falling back to `fr`
|
||||
* in a map that has `fr_FR` as a key.
|
||||
*/
|
||||
private fun <T> Map<String, T>.getOrStartsWith(s: String): T? = get(s) ?: run {
|
||||
entries.forEach { (key, value) ->
|
||||
if (key.startsWith(s)) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun <T> Map<String, Localized>.find(callback: (String, Localized) -> T?): T? {
|
||||
return getAndCall("en-US", callback) ?: getAndCall("en_US", callback) ?: getAndCall(
|
||||
"en",
|
||||
callback
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> Map<String, Localized>.findLocalized(callback: (Localized) -> T?): T? {
|
||||
return getBestLocale(getLocales(Resources.getSystem().configuration))?.let { callback(it) }
|
||||
}
|
||||
|
||||
private fun Map<String, Localized>.findString(
|
||||
fallback: String,
|
||||
callback: (Localized) -> String
|
||||
): String {
|
||||
return (find { _, localized -> callback(localized).nullIfEmpty() } ?: fallback).trim()
|
||||
}
|
||||
|
||||
private fun Map<String, Localized>.findLocalizedString(
|
||||
fallback: String,
|
||||
callback: (Localized) -> String
|
||||
): String {
|
||||
// @BLumia: it's possible a key of a certain Localized object is empty, so we still need a fallback
|
||||
return (
|
||||
findLocalized { localized -> callback(localized).trim().nullIfEmpty() } ?: findString(
|
||||
fallback,
|
||||
callback
|
||||
)
|
||||
).trim()
|
||||
}
|
||||
|
||||
internal object DonateComparator : Comparator<Product.Donate> {
|
||||
private val classes = listOf(
|
||||
Product.Donate.Regular::class,
|
||||
Product.Donate.Bitcoin::class,
|
||||
Product.Donate.Litecoin::class,
|
||||
Product.Donate.Flattr::class,
|
||||
Product.Donate.Liberapay::class,
|
||||
Product.Donate.OpenCollective::class
|
||||
)
|
||||
|
||||
override fun compare(donate1: Product.Donate, donate2: Product.Donate): Int {
|
||||
val index1 = classes.indexOf(donate1::class)
|
||||
val index2 = classes.indexOf(donate2::class)
|
||||
return when {
|
||||
index1 >= 0 && index2 == -1 -> -1
|
||||
index2 >= 0 && index1 == -1 -> 1
|
||||
else -> index1.compareTo(index2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parse(repositoryId: Long, inputStream: InputStream, callback: Callback) {
|
||||
val jsonParser = Json.factory.createParser(inputStream)
|
||||
if (jsonParser.nextToken() != JsonToken.START_OBJECT) {
|
||||
jsonParser.illegal()
|
||||
} else {
|
||||
jsonParser.forEachKey { it ->
|
||||
when {
|
||||
it.dictionary("repo") -> {
|
||||
var address = ""
|
||||
var mirrors = emptyList<String>()
|
||||
var name = ""
|
||||
var description = ""
|
||||
var version = 0
|
||||
var timestamp = 0L
|
||||
forEachKey {
|
||||
when {
|
||||
it.string("address") -> address = valueAsString
|
||||
it.array("mirrors") -> mirrors = collectDistinctNotEmptyStrings()
|
||||
it.string("name") -> name = valueAsString
|
||||
it.string("description") -> description = valueAsString
|
||||
it.number("version") -> version = valueAsInt
|
||||
it.number("timestamp") -> timestamp = valueAsLong
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
val realMirrors = (
|
||||
if (address.isNotEmpty()) {
|
||||
listOf(address)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
) + mirrors
|
||||
callback.onRepository(
|
||||
mirrors = realMirrors.distinct(),
|
||||
name = name,
|
||||
description = description,
|
||||
version = version,
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
|
||||
it.array("apps") -> forEach(JsonToken.START_OBJECT) {
|
||||
val product = parseProduct(repositoryId)
|
||||
callback.onProduct(product)
|
||||
}
|
||||
|
||||
it.dictionary("packages") -> forEachKey {
|
||||
if (it.token == JsonToken.START_ARRAY) {
|
||||
val packageName = it.key
|
||||
val releases = collectNotNull(JsonToken.START_OBJECT) { parseRelease() }
|
||||
callback.onReleases(packageName, releases)
|
||||
} else {
|
||||
skipChildren()
|
||||
}
|
||||
}
|
||||
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonParser.parseProduct(repositoryId: Long): Product {
|
||||
var packageName = ""
|
||||
var nameFallback = ""
|
||||
var summaryFallback = ""
|
||||
var descriptionFallback = ""
|
||||
var icon = ""
|
||||
var authorName = ""
|
||||
var authorEmail = ""
|
||||
var authorWeb = ""
|
||||
var source = ""
|
||||
var changelog = ""
|
||||
var web = ""
|
||||
var tracker = ""
|
||||
var added = 0L
|
||||
var updated = 0L
|
||||
var suggestedVersionCode = 0L
|
||||
var categories = emptyList<String>()
|
||||
var antiFeatures = emptyList<String>()
|
||||
val licenses = mutableListOf<String>()
|
||||
val donates = mutableListOf<Product.Donate>()
|
||||
val localizedMap = mutableMapOf<String, Localized>()
|
||||
forEachKey { it ->
|
||||
when {
|
||||
it.string("packageName") -> packageName = valueAsString
|
||||
it.string("name") -> nameFallback = valueAsString
|
||||
it.string("summary") -> summaryFallback = valueAsString
|
||||
it.string("description") -> descriptionFallback = valueAsString
|
||||
it.string("icon") -> icon = validateIcon(valueAsString)
|
||||
it.string("authorName") -> authorName = valueAsString
|
||||
it.string("authorEmail") -> authorEmail = valueAsString
|
||||
it.string("authorWebSite") -> authorWeb = valueAsString
|
||||
it.string("sourceCode") -> source = valueAsString
|
||||
it.string("changelog") -> changelog = valueAsString
|
||||
it.string("webSite") -> web = valueAsString
|
||||
it.string("issueTracker") -> tracker = valueAsString
|
||||
it.number("added") -> added = valueAsLong
|
||||
it.number("lastUpdated") -> updated = valueAsLong
|
||||
it.string("suggestedVersionCode") ->
|
||||
suggestedVersionCode =
|
||||
valueAsString.toLongOrNull() ?: 0L
|
||||
|
||||
it.array("categories") -> categories = collectDistinctNotEmptyStrings()
|
||||
it.array("antiFeatures") -> antiFeatures = collectDistinctNotEmptyStrings()
|
||||
it.string("license") -> licenses += valueAsString.split(',')
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
it.string("donate") -> donates += Product.Donate.Regular(valueAsString)
|
||||
it.string("bitcoin") -> donates += Product.Donate.Bitcoin(valueAsString)
|
||||
it.string("flattrID") -> donates += Product.Donate.Flattr(valueAsString)
|
||||
it.string("liberapayID") -> donates += Product.Donate.Liberapay(valueAsString)
|
||||
it.string("openCollective") -> donates += Product.Donate.OpenCollective(
|
||||
valueAsString
|
||||
)
|
||||
|
||||
it.dictionary("localized") -> forEachKey { it ->
|
||||
if (it.token == JsonToken.START_OBJECT) {
|
||||
val locale = it.key
|
||||
var name = ""
|
||||
var summary = ""
|
||||
var description = ""
|
||||
var whatsNew = ""
|
||||
var metadataIcon = ""
|
||||
var phone = emptyList<String>()
|
||||
var smallTablet = emptyList<String>()
|
||||
var largeTablet = emptyList<String>()
|
||||
forEachKey {
|
||||
when {
|
||||
it.string("name") -> name = valueAsString
|
||||
it.string("summary") -> summary = valueAsString
|
||||
it.string("description") -> description = valueAsString
|
||||
it.string("whatsNew") -> whatsNew = valueAsString
|
||||
it.string("icon") -> metadataIcon = valueAsString
|
||||
it.array("phoneScreenshots") ->
|
||||
phone =
|
||||
collectDistinctNotEmptyStrings()
|
||||
|
||||
it.array("sevenInchScreenshots") ->
|
||||
smallTablet =
|
||||
collectDistinctNotEmptyStrings()
|
||||
|
||||
it.array("tenInchScreenshots") ->
|
||||
largeTablet =
|
||||
collectDistinctNotEmptyStrings()
|
||||
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
val screenshots =
|
||||
if (sequenceOf(
|
||||
phone,
|
||||
smallTablet,
|
||||
largeTablet
|
||||
).any { it.isNotEmpty() }
|
||||
) {
|
||||
Screenshots(phone, smallTablet, largeTablet)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
localizedMap[locale] = Localized(
|
||||
name,
|
||||
summary,
|
||||
description,
|
||||
whatsNew,
|
||||
metadataIcon.nullIfEmpty()?.let { "$locale/$it" }.orEmpty(),
|
||||
screenshots
|
||||
)
|
||||
} else {
|
||||
skipChildren()
|
||||
}
|
||||
}
|
||||
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
val name = localizedMap.findLocalizedString(nameFallback) { it.name }
|
||||
val summary = localizedMap.findLocalizedString(summaryFallback) { it.summary }
|
||||
val description =
|
||||
localizedMap.findLocalizedString(descriptionFallback) { it.description }.replace(
|
||||
"\n",
|
||||
"<br/>"
|
||||
)
|
||||
val whatsNew = localizedMap.findLocalizedString("") { it.whatsNew }.replace("\n", "<br/>")
|
||||
val metadataIcon = localizedMap.findLocalizedString("") { it.metadataIcon }.ifEmpty {
|
||||
localizedMap.firstNotNullOfOrNull { it.value.metadataIcon }.orEmpty()
|
||||
}
|
||||
val screenshotPairs =
|
||||
localizedMap.find { key, localized -> localized.screenshots?.let { Pair(key, it) } }
|
||||
val screenshots = screenshotPairs
|
||||
?.let { (key, screenshots) ->
|
||||
screenshots.phone.asSequence()
|
||||
.map { Product.Screenshot(key, Product.Screenshot.Type.PHONE, it) } +
|
||||
screenshots.smallTablet.asSequence()
|
||||
.map {
|
||||
Product.Screenshot(
|
||||
key,
|
||||
Product.Screenshot.Type.SMALL_TABLET,
|
||||
it
|
||||
)
|
||||
} +
|
||||
screenshots.largeTablet.asSequence()
|
||||
.map {
|
||||
Product.Screenshot(
|
||||
key,
|
||||
Product.Screenshot.Type.LARGE_TABLET,
|
||||
it
|
||||
)
|
||||
}
|
||||
}
|
||||
.orEmpty().toList()
|
||||
return Product(
|
||||
repositoryId,
|
||||
packageName,
|
||||
name,
|
||||
summary,
|
||||
description,
|
||||
whatsNew,
|
||||
icon,
|
||||
metadataIcon,
|
||||
Product.Author(authorName, authorEmail, authorWeb),
|
||||
source,
|
||||
changelog,
|
||||
web,
|
||||
tracker,
|
||||
added,
|
||||
updated,
|
||||
suggestedVersionCode,
|
||||
categories,
|
||||
antiFeatures,
|
||||
licenses,
|
||||
donates.sortedWith(DonateComparator),
|
||||
screenshots,
|
||||
emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonParser.parseRelease(): Release {
|
||||
var version = ""
|
||||
var versionCode = 0L
|
||||
var added = 0L
|
||||
var size = 0L
|
||||
var minSdkVersion = 0
|
||||
var targetSdkVersion = 0
|
||||
var maxSdkVersion = 0
|
||||
var source = ""
|
||||
var release = ""
|
||||
var hash = ""
|
||||
var hashTypeCandidate = ""
|
||||
var signature = ""
|
||||
var obbMain = ""
|
||||
var obbMainHash = ""
|
||||
var obbPatch = ""
|
||||
var obbPatchHash = ""
|
||||
val permissions = linkedSetOf<String>()
|
||||
var features = emptyList<String>()
|
||||
var platforms = emptyList<String>()
|
||||
forEachKey {
|
||||
when {
|
||||
it.string("versionName") -> version = valueAsString
|
||||
it.number("versionCode") -> versionCode = valueAsLong
|
||||
it.number("added") -> added = valueAsLong
|
||||
it.number("size") -> size = valueAsLong
|
||||
it.number("minSdkVersion") -> minSdkVersion = valueAsInt
|
||||
it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt
|
||||
it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt
|
||||
it.string("srcname") -> source = valueAsString
|
||||
it.string("apkName") -> release = valueAsString
|
||||
it.string("hash") -> hash = valueAsString
|
||||
it.string("hashType") -> hashTypeCandidate = valueAsString
|
||||
it.string("sig") -> signature = valueAsString
|
||||
it.string("obbMainFile") -> obbMain = valueAsString
|
||||
it.string("obbMainFileSha256") -> obbMainHash = valueAsString
|
||||
it.string("obbPatchFile") -> obbPatch = valueAsString
|
||||
it.string("obbPatchFileSha256") -> obbPatchHash = valueAsString
|
||||
it.array("uses-permission") -> collectPermissions(permissions, 0)
|
||||
it.array("uses-permission-sdk-23") -> collectPermissions(permissions, 23)
|
||||
it.array("features") -> features = collectDistinctNotEmptyStrings()
|
||||
it.array("nativecode") -> platforms = collectDistinctNotEmptyStrings()
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
val hashType =
|
||||
if (hash.isNotEmpty() && hashTypeCandidate.isEmpty()) "sha256" else hashTypeCandidate
|
||||
val obbMainHashType = if (obbMainHash.isNotEmpty()) "sha256" else ""
|
||||
val obbPatchHashType = if (obbPatchHash.isNotEmpty()) "sha256" else ""
|
||||
return Release(
|
||||
false,
|
||||
version,
|
||||
versionCode,
|
||||
added,
|
||||
size,
|
||||
minSdkVersion,
|
||||
targetSdkVersion,
|
||||
maxSdkVersion,
|
||||
source,
|
||||
release,
|
||||
hash,
|
||||
hashType,
|
||||
signature,
|
||||
obbMain,
|
||||
obbMainHash,
|
||||
obbMainHashType,
|
||||
obbPatch,
|
||||
obbPatchHash,
|
||||
obbPatchHashType,
|
||||
permissions.toList(),
|
||||
features,
|
||||
platforms,
|
||||
emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonParser.collectPermissions(permissions: LinkedHashSet<String>, minSdk: Int) {
|
||||
forEach(JsonToken.START_ARRAY) {
|
||||
val firstToken = nextToken()
|
||||
val permission = if (firstToken == JsonToken.VALUE_STRING) valueAsString else ""
|
||||
if (firstToken != JsonToken.END_ARRAY) {
|
||||
val secondToken = nextToken()
|
||||
val maxSdk = if (secondToken == JsonToken.VALUE_NUMBER_INT) valueAsInt else 0
|
||||
if (permission.isNotEmpty() &&
|
||||
SdkCheck.sdk >= minSdk && (
|
||||
maxSdk <= 0 ||
|
||||
SdkCheck.sdk <= maxSdk
|
||||
)
|
||||
) {
|
||||
permissions.add(permission)
|
||||
}
|
||||
if (secondToken != JsonToken.END_ARRAY) {
|
||||
while (true) {
|
||||
val token = nextToken()
|
||||
if (token == JsonToken.END_ARRAY) {
|
||||
break
|
||||
} else if (token.isStructStart) {
|
||||
skipChildren()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateIcon(icon: String): String {
|
||||
return if (icon.endsWith(".xml")) "" else icon
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package com.looker.droidify.index
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.common.extension.fingerprint
|
||||
import com.looker.core.common.extension.toFormattedString
|
||||
import com.looker.core.common.result.Result
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.utility.extension.android.Android
|
||||
import com.looker.droidify.utility.getProgress
|
||||
import com.looker.network.Downloader
|
||||
import com.looker.network.NetworkResponse
|
||||
import java.io.File
|
||||
import java.security.CodeSigner
|
||||
import java.security.cert.Certificate
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarFile
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
object RepositoryUpdater {
|
||||
enum class Stage {
|
||||
DOWNLOAD, PROCESS, MERGE, COMMIT
|
||||
}
|
||||
|
||||
// TODO Add support for Index-V2 and also cleanup everything here
|
||||
private enum class IndexType(
|
||||
val jarName: String,
|
||||
val contentName: String
|
||||
) {
|
||||
INDEX_V1("index-v1.jar", "index-v1.json")
|
||||
}
|
||||
|
||||
enum class ErrorType {
|
||||
NETWORK, HTTP, VALIDATION, PARSING
|
||||
}
|
||||
|
||||
class UpdateException : Exception {
|
||||
val errorType: ErrorType
|
||||
|
||||
constructor(errorType: ErrorType, message: String) : super(message) {
|
||||
this.errorType = errorType
|
||||
}
|
||||
|
||||
constructor(errorType: ErrorType, message: String, cause: Exception) : super(
|
||||
message,
|
||||
cause
|
||||
) {
|
||||
this.errorType = errorType
|
||||
}
|
||||
}
|
||||
|
||||
private val updaterLock = Any()
|
||||
private val cleanupLock = Any()
|
||||
|
||||
private lateinit var downloader: Downloader
|
||||
|
||||
fun init(scope: CoroutineScope, downloader: Downloader) {
|
||||
this.downloader = downloader
|
||||
scope.launch {
|
||||
// No need of mutex because it is in same coroutine scope
|
||||
var lastDisabled = emptyMap<Long, Boolean>()
|
||||
Database.RepositoryAdapter
|
||||
.getAllRemovedStream()
|
||||
.map { deletedRepos ->
|
||||
deletedRepos
|
||||
.filterNot { it.key in lastDisabled.keys }
|
||||
.also { lastDisabled = deletedRepos }
|
||||
}
|
||||
// To not perform complete cleanup on startup
|
||||
.drop(1)
|
||||
.filter { it.isNotEmpty() }
|
||||
.collect(Database.RepositoryAdapter::cleanup)
|
||||
}
|
||||
}
|
||||
|
||||
fun await() {
|
||||
synchronized(updaterLock) { }
|
||||
}
|
||||
|
||||
suspend fun update(
|
||||
context: Context,
|
||||
repository: Repository,
|
||||
unstable: Boolean,
|
||||
callback: (Stage, Long, Long?) -> Unit
|
||||
) = update(
|
||||
context = context,
|
||||
repository = repository,
|
||||
unstable = unstable,
|
||||
indexTypes = listOf(IndexType.INDEX_V1),
|
||||
callback = callback
|
||||
)
|
||||
|
||||
private suspend fun update(
|
||||
context: Context,
|
||||
repository: Repository,
|
||||
unstable: Boolean,
|
||||
indexTypes: List<IndexType>,
|
||||
callback: (Stage, Long, Long?) -> Unit
|
||||
): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||
val indexType = indexTypes[0]
|
||||
when (val request = downloadIndex(context, repository, indexType, callback)) {
|
||||
is Result.Error -> {
|
||||
val result = request.data
|
||||
?: return@withContext Result.Error(request.exception, false)
|
||||
|
||||
val file = request.data?.file
|
||||
?: return@withContext Result.Error(request.exception, false)
|
||||
file.delete()
|
||||
if (result.statusCode == 404 && indexTypes.isNotEmpty()) {
|
||||
update(
|
||||
context = context,
|
||||
repository = repository,
|
||||
indexTypes = indexTypes.subList(1, indexTypes.size),
|
||||
unstable = unstable,
|
||||
callback = callback
|
||||
)
|
||||
} else {
|
||||
Result.Error(
|
||||
UpdateException(
|
||||
ErrorType.HTTP,
|
||||
"Invalid response: HTTP ${result.statusCode}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is Result.Success -> {
|
||||
if (request.data.isUnmodified) {
|
||||
request.data.file.delete()
|
||||
Result.Success(false)
|
||||
} else {
|
||||
try {
|
||||
val isFileParsedSuccessfully = processFile(
|
||||
context = context,
|
||||
repository = repository,
|
||||
indexType = indexType,
|
||||
unstable = unstable,
|
||||
file = request.data.file,
|
||||
lastModified = request.data.lastModified,
|
||||
entityTag = request.data.entityTag,
|
||||
callback = callback
|
||||
)
|
||||
Result.Success(isFileParsedSuccessfully)
|
||||
} catch (e: UpdateException) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadIndex(
|
||||
context: Context,
|
||||
repository: Repository,
|
||||
indexType: IndexType,
|
||||
callback: (Stage, Long, Long?) -> Unit
|
||||
): Result<IndexFile> = withContext(Dispatchers.IO) {
|
||||
val file = Cache.getTemporaryFile(context)
|
||||
val result = downloader.downloadToFile(
|
||||
url = Uri.parse(repository.address).buildUpon()
|
||||
.appendPath(indexType.jarName).build().toString(),
|
||||
target = file,
|
||||
headers = {
|
||||
ifModifiedSince(repository.lastModified)
|
||||
etag(repository.entityTag)
|
||||
authentication(repository.authentication)
|
||||
}
|
||||
) { read, total ->
|
||||
callback(Stage.DOWNLOAD, read.value, total.value.takeIf { it != 0L })
|
||||
}
|
||||
|
||||
when (result) {
|
||||
is NetworkResponse.Success -> {
|
||||
Result.Success(
|
||||
IndexFile(
|
||||
isUnmodified = result.statusCode == 304,
|
||||
lastModified = result.lastModified?.toFormattedString() ?: "",
|
||||
entityTag = result.etag ?: "",
|
||||
statusCode = result.statusCode,
|
||||
file = file
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is NetworkResponse.Error -> {
|
||||
file.delete()
|
||||
when (result) {
|
||||
is NetworkResponse.Error.Http -> {
|
||||
val errorType = if (result.statusCode in 400..499) {
|
||||
ErrorType.HTTP
|
||||
} else {
|
||||
ErrorType.NETWORK
|
||||
}
|
||||
|
||||
Result.Error(
|
||||
UpdateException(
|
||||
errorType = errorType,
|
||||
message = "Failed with Status: ${result.statusCode}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is NetworkResponse.Error.ConnectionTimeout -> Result.Error(result.exception)
|
||||
is NetworkResponse.Error.IO -> Result.Error(result.exception)
|
||||
is NetworkResponse.Error.SocketTimeout -> Result.Error(result.exception)
|
||||
is NetworkResponse.Error.Unknown -> Result.Error(result.exception)
|
||||
// TODO: Add Validator
|
||||
is NetworkResponse.Error.Validation -> Result.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processFile(
|
||||
context: Context,
|
||||
repository: Repository,
|
||||
indexType: IndexType,
|
||||
unstable: Boolean,
|
||||
file: File,
|
||||
lastModified: String,
|
||||
entityTag: String,
|
||||
callback: (Stage, Long, Long?) -> Unit
|
||||
): Boolean {
|
||||
var rollback = true
|
||||
return synchronized(updaterLock) {
|
||||
try {
|
||||
val jarFile = JarFile(file, true)
|
||||
val indexEntry = jarFile.getEntry(indexType.contentName) as JarEntry
|
||||
val total = indexEntry.size
|
||||
Database.UpdaterAdapter.createTemporaryTable()
|
||||
val features = context.packageManager.systemAvailableFeatures
|
||||
.asSequence().map { it.name }.toSet() + setOf("android.hardware.touchscreen")
|
||||
|
||||
var changedRepository: Repository? = null
|
||||
|
||||
val mergerFile = Cache.getTemporaryFile(context)
|
||||
try {
|
||||
val unmergedProducts = mutableListOf<Product>()
|
||||
val unmergedReleases = mutableListOf<Pair<String, List<Release>>>()
|
||||
IndexMerger(mergerFile).use { indexMerger ->
|
||||
jarFile.getInputStream(indexEntry).getProgress {
|
||||
callback(Stage.PROCESS, it, total)
|
||||
}.use { entryStream ->
|
||||
IndexV1Parser.parse(
|
||||
repository.id,
|
||||
entryStream,
|
||||
object : IndexV1Parser.Callback {
|
||||
override fun onRepository(
|
||||
mirrors: List<String>,
|
||||
name: String,
|
||||
description: String,
|
||||
version: Int,
|
||||
timestamp: Long
|
||||
) {
|
||||
changedRepository = repository.update(
|
||||
mirrors,
|
||||
name,
|
||||
description,
|
||||
version,
|
||||
lastModified,
|
||||
entityTag,
|
||||
timestamp
|
||||
)
|
||||
}
|
||||
|
||||
override fun onProduct(product: Product) {
|
||||
if (Thread.interrupted()) {
|
||||
throw InterruptedException()
|
||||
}
|
||||
unmergedProducts += product
|
||||
if (unmergedProducts.size >= 50) {
|
||||
indexMerger.addProducts(unmergedProducts)
|
||||
unmergedProducts.clear()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReleases(
|
||||
packageName: String,
|
||||
releases: List<Release>
|
||||
) {
|
||||
if (Thread.interrupted()) {
|
||||
throw InterruptedException()
|
||||
}
|
||||
unmergedReleases += Pair(packageName, releases)
|
||||
if (unmergedReleases.size >= 50) {
|
||||
indexMerger.addReleases(unmergedReleases)
|
||||
unmergedReleases.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (Thread.interrupted()) {
|
||||
throw InterruptedException()
|
||||
}
|
||||
if (unmergedProducts.isNotEmpty()) {
|
||||
indexMerger.addProducts(unmergedProducts)
|
||||
unmergedProducts.clear()
|
||||
}
|
||||
if (unmergedReleases.isNotEmpty()) {
|
||||
indexMerger.addReleases(unmergedReleases)
|
||||
unmergedReleases.clear()
|
||||
}
|
||||
var progress = 0
|
||||
indexMerger.forEach(repository.id, 50) { products, totalCount ->
|
||||
if (Thread.interrupted()) {
|
||||
throw InterruptedException()
|
||||
}
|
||||
progress += products.size
|
||||
callback(
|
||||
Stage.MERGE,
|
||||
progress.toLong(),
|
||||
totalCount.toLong()
|
||||
)
|
||||
Database.UpdaterAdapter.putTemporary(
|
||||
products
|
||||
.map { transformProduct(it, features, unstable) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
mergerFile.delete()
|
||||
}
|
||||
|
||||
val workRepository = changedRepository ?: repository
|
||||
if (workRepository.timestamp < repository.timestamp) {
|
||||
throw UpdateException(
|
||||
ErrorType.VALIDATION,
|
||||
"New index is older than current index:" +
|
||||
" ${workRepository.timestamp} < ${repository.timestamp}"
|
||||
)
|
||||
}
|
||||
|
||||
val fingerprint = indexEntry
|
||||
.codeSigner
|
||||
.certificate
|
||||
.fingerprint()
|
||||
.uppercase()
|
||||
|
||||
val commitRepository = if (!workRepository.fingerprint.equals(
|
||||
fingerprint,
|
||||
ignoreCase = true
|
||||
)
|
||||
) {
|
||||
if (workRepository.fingerprint.isNotEmpty()) {
|
||||
throw UpdateException(
|
||||
ErrorType.VALIDATION,
|
||||
"Certificate fingerprints do not match"
|
||||
)
|
||||
}
|
||||
|
||||
workRepository.copy(fingerprint = fingerprint)
|
||||
} else {
|
||||
workRepository
|
||||
}
|
||||
if (Thread.interrupted()) {
|
||||
throw InterruptedException()
|
||||
}
|
||||
callback(Stage.COMMIT, 0, null)
|
||||
synchronized(cleanupLock) {
|
||||
Database.UpdaterAdapter.finishTemporary(commitRepository, true)
|
||||
}
|
||||
rollback = false
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
throw when (e) {
|
||||
is UpdateException, is InterruptedException -> e
|
||||
else -> UpdateException(ErrorType.PARSING, "Error parsing index", e)
|
||||
}
|
||||
} finally {
|
||||
file.delete()
|
||||
if (rollback) {
|
||||
Database.UpdaterAdapter.finishTemporary(repository, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@get:Throws(UpdateException::class)
|
||||
private val JarEntry.codeSigner: CodeSigner
|
||||
get() = codeSigners?.singleOrNull()
|
||||
?: throw UpdateException(
|
||||
ErrorType.VALIDATION,
|
||||
"index.jar must be signed by a single code signer"
|
||||
)
|
||||
|
||||
@get:Throws(UpdateException::class)
|
||||
private val CodeSigner.certificate: Certificate
|
||||
get() = signerCertPath?.certificates?.singleOrNull()
|
||||
?: throw UpdateException(
|
||||
ErrorType.VALIDATION,
|
||||
"index.jar code signer should have only one certificate"
|
||||
)
|
||||
|
||||
private fun transformProduct(
|
||||
product: Product,
|
||||
features: Set<String>,
|
||||
unstable: Boolean
|
||||
): Product {
|
||||
val releasePairs = product.releases
|
||||
.distinctBy { it.identifier }
|
||||
.sortedByDescending { it.versionCode }
|
||||
.map { release ->
|
||||
val incompatibilities = mutableListOf<Release.Incompatibility>()
|
||||
if (release.minSdkVersion > 0 && SdkCheck.sdk < release.minSdkVersion) {
|
||||
incompatibilities += Release.Incompatibility.MinSdk
|
||||
}
|
||||
if (release.maxSdkVersion > 0 && SdkCheck.sdk > release.maxSdkVersion) {
|
||||
incompatibilities += Release.Incompatibility.MaxSdk
|
||||
}
|
||||
if (release.platforms.isNotEmpty() &&
|
||||
(release.platforms intersect Android.platforms).isEmpty()
|
||||
) {
|
||||
incompatibilities += Release.Incompatibility.Platform
|
||||
}
|
||||
incompatibilities += (release.features - features).sorted()
|
||||
.map { Release.Incompatibility.Feature(it) }
|
||||
Pair(release, incompatibilities.toList())
|
||||
}
|
||||
|
||||
val predicate: (Release) -> Boolean = {
|
||||
unstable ||
|
||||
product.suggestedVersionCode <= 0 ||
|
||||
it.versionCode <= product.suggestedVersionCode
|
||||
}
|
||||
|
||||
val firstSelected =
|
||||
releasePairs.firstOrNull { it.second.isEmpty() && predicate(it.first) }
|
||||
?: releasePairs.firstOrNull { predicate(it.first) }
|
||||
|
||||
val releases = releasePairs
|
||||
.map { (release, incompatibilities) ->
|
||||
release.copy(
|
||||
incompatibilities = incompatibilities,
|
||||
selected = firstSelected?.let {
|
||||
it.first.versionCode == release.versionCode &&
|
||||
it.second == incompatibilities
|
||||
} ?: false
|
||||
)
|
||||
}
|
||||
return product.copy(releases = releases)
|
||||
}
|
||||
}
|
||||
|
||||
data class IndexFile(
|
||||
val isUnmodified: Boolean,
|
||||
val lastModified: String,
|
||||
val entityTag: String,
|
||||
val statusCode: Int,
|
||||
val file: File
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
class InstalledItem(
|
||||
val packageName: String,
|
||||
val version: String,
|
||||
val versionCode: Long,
|
||||
val signature: String
|
||||
)
|
||||
102
app/src/main/kotlin/com/looker/droidify/model/Product.kt
Normal file
102
app/src/main/kotlin/com/looker/droidify/model/Product.kt
Normal file
@@ -0,0 +1,102 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
data class Product(
|
||||
var repositoryId: Long,
|
||||
val packageName: String,
|
||||
val name: String,
|
||||
val summary: String,
|
||||
var description: String,
|
||||
val whatsNew: String,
|
||||
val icon: String,
|
||||
val metadataIcon: String,
|
||||
val author: Author,
|
||||
val source: String,
|
||||
val changelog: String,
|
||||
val web: String,
|
||||
val tracker: String,
|
||||
val added: Long,
|
||||
val updated: Long,
|
||||
val suggestedVersionCode: Long,
|
||||
val categories: List<String>,
|
||||
val antiFeatures: List<String>,
|
||||
val licenses: List<String>,
|
||||
val donates: List<Donate>,
|
||||
val screenshots: List<Screenshot>,
|
||||
val releases: List<Release>
|
||||
) {
|
||||
data class Author(val name: String, val email: String, val web: String)
|
||||
|
||||
sealed class Donate {
|
||||
data class Regular(val url: String) : Donate()
|
||||
data class Bitcoin(val address: String) : Donate()
|
||||
data class Litecoin(val address: String) : Donate()
|
||||
data class Flattr(val id: String) : Donate()
|
||||
data class Liberapay(val id: String) : Donate()
|
||||
data class OpenCollective(val id: String) : Donate()
|
||||
}
|
||||
|
||||
class Screenshot(val locale: String, val type: Type, val path: String) {
|
||||
enum class Type(val jsonName: String) {
|
||||
PHONE("phone"),
|
||||
SMALL_TABLET("smallTablet"),
|
||||
LARGE_TABLET("largeTablet")
|
||||
}
|
||||
|
||||
val identifier: String
|
||||
get() = "$locale.${type.name}.$path"
|
||||
}
|
||||
|
||||
// Same releases with different signatures
|
||||
val selectedReleases: List<Release>
|
||||
get() = releases.filter { it.selected }
|
||||
|
||||
val displayRelease: Release?
|
||||
get() = selectedReleases.firstOrNull() ?: releases.firstOrNull()
|
||||
|
||||
val version: String
|
||||
get() = displayRelease?.version.orEmpty()
|
||||
|
||||
val versionCode: Long
|
||||
get() = selectedReleases.firstOrNull()?.versionCode ?: 0L
|
||||
|
||||
val compatible: Boolean
|
||||
get() = selectedReleases.firstOrNull()?.incompatibilities?.isEmpty() == true
|
||||
|
||||
val signatures: List<String>
|
||||
get() = selectedReleases.mapNotNull { it.signature.ifBlank { null } }.distinct().toList()
|
||||
|
||||
fun item(): ProductItem {
|
||||
return ProductItem(
|
||||
repositoryId,
|
||||
packageName,
|
||||
name,
|
||||
summary,
|
||||
icon,
|
||||
metadataIcon,
|
||||
version,
|
||||
"",
|
||||
compatible,
|
||||
false,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
fun canUpdate(installedItem: InstalledItem?): Boolean {
|
||||
return installedItem != null && compatible && versionCode > installedItem.versionCode &&
|
||||
installedItem.signature in signatures
|
||||
}
|
||||
}
|
||||
|
||||
fun List<Pair<Product, Repository>>.findSuggested(
|
||||
installedItem: InstalledItem?
|
||||
): Pair<Product, Repository>? = maxWithOrNull(
|
||||
compareBy(
|
||||
{ (product, _) ->
|
||||
product.compatible &&
|
||||
(installedItem == null || installedItem.signature in product.signatures)
|
||||
},
|
||||
{ (product, _) ->
|
||||
product.versionCode
|
||||
}
|
||||
)
|
||||
)
|
||||
30
app/src/main/kotlin/com/looker/droidify/model/ProductItem.kt
Normal file
30
app/src/main/kotlin/com/looker/droidify/model/ProductItem.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
data class ProductItem(
|
||||
var repositoryId: Long,
|
||||
var packageName: String,
|
||||
var name: String,
|
||||
var summary: String,
|
||||
val icon: String,
|
||||
val metadataIcon: String,
|
||||
val version: String,
|
||||
var installedVersion: String,
|
||||
var compatible: Boolean,
|
||||
var canUpdate: Boolean,
|
||||
var matchRank: Int
|
||||
) {
|
||||
sealed class Section : Parcelable {
|
||||
|
||||
@Parcelize
|
||||
data object All : Section()
|
||||
|
||||
@Parcelize
|
||||
data class Category(val name: String) : Section()
|
||||
|
||||
@Parcelize
|
||||
data class Repository(val id: Long, val name: String) : Section()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
data class ProductPreference(val ignoreUpdates: Boolean, val ignoreVersionCode: Long) {
|
||||
fun shouldIgnoreUpdate(versionCode: Long): Boolean {
|
||||
return ignoreUpdates || ignoreVersionCode == versionCode
|
||||
}
|
||||
}
|
||||
46
app/src/main/kotlin/com/looker/droidify/model/Release.kt
Normal file
46
app/src/main/kotlin/com/looker/droidify/model/Release.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
data class Release(
|
||||
val selected: Boolean,
|
||||
val version: String,
|
||||
val versionCode: Long,
|
||||
val added: Long,
|
||||
val size: Long,
|
||||
val minSdkVersion: Int,
|
||||
val targetSdkVersion: Int,
|
||||
val maxSdkVersion: Int,
|
||||
val source: String,
|
||||
val release: String,
|
||||
val hash: String,
|
||||
val hashType: String,
|
||||
val signature: String,
|
||||
val obbMain: String,
|
||||
val obbMainHash: String,
|
||||
val obbMainHashType: String,
|
||||
val obbPatch: String,
|
||||
val obbPatchHash: String,
|
||||
val obbPatchHashType: String,
|
||||
val permissions: List<String>,
|
||||
val features: List<String>,
|
||||
val platforms: List<String>,
|
||||
val incompatibilities: List<Incompatibility>
|
||||
) {
|
||||
sealed class Incompatibility {
|
||||
object MinSdk : Incompatibility()
|
||||
object MaxSdk : Incompatibility()
|
||||
object Platform : Incompatibility()
|
||||
data class Feature(val feature: String) : Incompatibility()
|
||||
}
|
||||
|
||||
val identifier: String
|
||||
get() = "$versionCode.$hash"
|
||||
|
||||
fun getDownloadUrl(repository: Repository): String {
|
||||
return Uri.parse(repository.address).buildUpon().appendPath(release).build().toString()
|
||||
}
|
||||
|
||||
val cacheFileName: String
|
||||
get() = "${hash.replace('/', '-')}.apk"
|
||||
}
|
||||
415
app/src/main/kotlin/com/looker/droidify/model/Repository.kt
Normal file
415
app/src/main/kotlin/com/looker/droidify/model/Repository.kt
Normal file
@@ -0,0 +1,415 @@
|
||||
package com.looker.droidify.model
|
||||
|
||||
import com.looker.core.common.extension.isOnion
|
||||
import java.net.URL
|
||||
|
||||
data class Repository(
|
||||
var id: Long,
|
||||
val address: String,
|
||||
val mirrors: List<String>,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val version: Int,
|
||||
val enabled: Boolean,
|
||||
val fingerprint: String,
|
||||
val lastModified: String,
|
||||
val entityTag: String,
|
||||
val updated: Long,
|
||||
val timestamp: Long,
|
||||
val authentication: String
|
||||
) {
|
||||
|
||||
/**
|
||||
* Remove all onion addresses and supply it as random address
|
||||
*
|
||||
* If the list only contains onion urls we will provide the default address
|
||||
*/
|
||||
val randomAddress: String
|
||||
get() = (mirrors + address)
|
||||
.filter { !it.isOnion }
|
||||
.randomOrNull() ?: address
|
||||
|
||||
fun edit(address: String, fingerprint: String, authentication: String): Repository {
|
||||
val isAddressChanged = this.address != address
|
||||
val isFingerprintChanged = this.fingerprint != fingerprint
|
||||
val shouldForceUpdate = isAddressChanged || isFingerprintChanged
|
||||
return copy(
|
||||
address = address,
|
||||
fingerprint = fingerprint,
|
||||
lastModified = if (shouldForceUpdate) "" else lastModified,
|
||||
entityTag = if (shouldForceUpdate) "" else entityTag,
|
||||
authentication = authentication
|
||||
)
|
||||
}
|
||||
|
||||
fun update(
|
||||
mirrors: List<String>,
|
||||
name: String,
|
||||
description: String,
|
||||
version: Int,
|
||||
lastModified: String,
|
||||
entityTag: String,
|
||||
timestamp: Long
|
||||
): Repository {
|
||||
return copy(
|
||||
mirrors = mirrors,
|
||||
name = name,
|
||||
description = description,
|
||||
version = if (version >= 0) version else this.version,
|
||||
lastModified = lastModified,
|
||||
entityTag = entityTag,
|
||||
updated = System.currentTimeMillis(),
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
|
||||
fun enable(enabled: Boolean): Repository {
|
||||
return copy(enabled = enabled, lastModified = "", entityTag = "")
|
||||
}
|
||||
|
||||
@Suppress("SpellCheckingInspection")
|
||||
companion object {
|
||||
|
||||
fun newRepository(
|
||||
address: String,
|
||||
fingerprint: String,
|
||||
authentication: String
|
||||
): Repository {
|
||||
val name = try {
|
||||
URL(address).let { "${it.host}${it.path}" }
|
||||
} catch (e: Exception) {
|
||||
address
|
||||
}
|
||||
return defaultRepository(address, name, "", 0, true, fingerprint, authentication)
|
||||
}
|
||||
|
||||
private fun defaultRepository(
|
||||
address: String,
|
||||
name: String,
|
||||
description: String,
|
||||
version: Int = 21,
|
||||
enabled: Boolean = false,
|
||||
fingerprint: String,
|
||||
authentication: String = ""
|
||||
): Repository {
|
||||
return Repository(
|
||||
-1, address, emptyList(), name, description, version, enabled,
|
||||
fingerprint, "", "", 0L, 0L, authentication
|
||||
)
|
||||
}
|
||||
|
||||
val defaultRepositories = listOf(
|
||||
defaultRepository(
|
||||
address = "https://f-droid.org/repo",
|
||||
name = "F-Droid",
|
||||
description = "The official F-Droid Free Software repos" +
|
||||
"itory. Everything in this repository is always buil" +
|
||||
"t from the source code.",
|
||||
enabled = true,
|
||||
fingerprint = "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://f-droid.org/archive",
|
||||
name = "F-Droid Archive",
|
||||
description = "The archive of the official F-Droid Free" +
|
||||
" Software repository. Apps here are old and can co" +
|
||||
"ntain known vulnerabilities and security issues!",
|
||||
fingerprint = "43238D512C1E5EB2D6569F4A3AFBF5523418B82E0A3ED1552770ABB9A9C9CCAB"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://guardianproject.info/fdroid/repo",
|
||||
name = "Guardian Project Official Releases",
|
||||
description = "The official repository of The Guardian " +
|
||||
"Project apps for use with the F-Droid client. Appl" +
|
||||
"ications in this repository are official binaries " +
|
||||
"built by the original application developers and " +
|
||||
"signed by the same key as the APKs that are relea" +
|
||||
"sed in the Google Play Store.",
|
||||
fingerprint = "B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://guardianproject.info/fdroid/archive",
|
||||
name = "Guardian Project Archive",
|
||||
description = "The official repository of The Guardian Pr" +
|
||||
"oject apps for use with the F-Droid client. This con" +
|
||||
"tains older versions of applications from the main repository.",
|
||||
fingerprint = "B7C2EEFD8DAC7806AF67DFCD92EB18126BC08312A7F2D6F3862E46013C7A6135"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://apt.izzysoft.de/fdroid/repo",
|
||||
name = "IzzyOnDroid F-Droid Repo",
|
||||
description = "This is a repository of apps to be used with" +
|
||||
" F-Droid the original application developers, taken" +
|
||||
" from the resp. repositories (mostly GitHub). At thi" +
|
||||
"s moment I cannot give guarantees on regular updates" +
|
||||
" for all of them, though most are checked multiple times a week ",
|
||||
enabled = true,
|
||||
fingerprint = "3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://microg.org/fdroid/repo",
|
||||
name = "MicroG Project",
|
||||
description = "The official repository for MicroG." +
|
||||
" MicroG is a lightweight open-source implementation" +
|
||||
" of Google Play Services.",
|
||||
fingerprint = "9BD06727E62796C0130EB6DAB39B73157451582CBD138E86C468ACC395D14165"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://repo.netsyms.com/fdroid/repo",
|
||||
name = "Netsyms Technologies",
|
||||
description = "Official collection of open-source apps created" +
|
||||
" by Netsyms Technologies.",
|
||||
fingerprint = "2581BA7B32D3AB443180C4087CAB6A7E8FB258D3A6E98870ECB3C675E4D64489"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.bromite.org/fdroid/repo",
|
||||
name = "Bromite",
|
||||
description = "The official repository for Bromite. " +
|
||||
"Bromite is a Chromium with ad blocking and enhanced p" +
|
||||
"rivacy.",
|
||||
fingerprint = "E1EE5CD076D7B0DC84CB2B45FB78B86DF2EB39A3B6C56BA3DC292A5E0C3B9504"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://molly.im/fdroid/foss/fdroid/repo",
|
||||
name = "Molly",
|
||||
description = "The official repository for Molly. " +
|
||||
"Molly is a fork of Signal focused on security.",
|
||||
fingerprint = "5198DAEF37FC23C14D5EE32305B2AF45787BD7DF2034DE33AD302BDB3446DF74"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://archive.newpipe.net/fdroid/repo",
|
||||
name = "NewPipe",
|
||||
description = "The official repository for NewPipe." +
|
||||
" NewPipe is a lightweight client for Youtube, PeerTube" +
|
||||
", Soundcloud, etc.",
|
||||
fingerprint = "E2402C78F9B97C6C89E97DB914A2751FDA1D02FE2039CC0897A462BDB57E7501"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://www.collaboraoffice.com/downloads/fdroid/repo",
|
||||
name = "Collabora Office",
|
||||
description = "Collabora Office is an office suite based on LibreOffice.",
|
||||
fingerprint = "573258C84E149B5F4D9299E7434B2B69A8410372921D4AE586BA91EC767892CC"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.libretro.com/repo",
|
||||
name = "LibRetro",
|
||||
description = "The official canary repository for this great" +
|
||||
" retro emulators hub.",
|
||||
fingerprint = "3F05B24D497515F31FEAB421297C79B19552C5C81186B3750B7C131EF41D733D"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://cdn.kde.org/android/fdroid/repo",
|
||||
name = "KDE Android",
|
||||
description = "The official nightly repository for KDE Android apps.",
|
||||
fingerprint = "B3EBE10AFA6C5C400379B34473E843D686C61AE6AD33F423C98AF903F056523F"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://calyxos.gitlab.io/calyx-fdroid-repo/fdroid/repo",
|
||||
name = "Calyx OS Repo",
|
||||
description = "The official Calyx Labs F-Droid repository.",
|
||||
fingerprint = "C44D58B4547DE5096138CB0B34A1CC99DAB3B4274412ED753FCCBFC11DC1B7B6"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://divestos.org/fdroid/official",
|
||||
name = "Divest OS Repo",
|
||||
description = "The official Divest OS F-Droid repository.",
|
||||
fingerprint = "E4BE8D6ABFA4D9D4FEEF03CDDA7FF62A73FD64B75566F6DD4E5E577550BE8467"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.fedilab.app/repo",
|
||||
name = "Fedilab",
|
||||
description = "The official repository for Fedilab. Fedilab is a " +
|
||||
"multi-accounts client for Mastodon, Peertube, and other free" +
|
||||
" software social networks.",
|
||||
fingerprint = "11F0A69910A4280E2CD3CCC3146337D006BE539B18E1A9FEACE15FF757A94FEB"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://store.nethunter.com/repo",
|
||||
name = "Kali Nethunter",
|
||||
description = "Kali Nethunter's official selection of original b" +
|
||||
"inaries.",
|
||||
fingerprint = "7E418D34C3AD4F3C37D7E6B0FACE13332364459C862134EB099A3BDA2CCF4494"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://secfirst.org/fdroid/repo",
|
||||
name = "Umbrella",
|
||||
description = "The official repository for Umbrella. Umbrella is" +
|
||||
" a collection of security advices, tutorials, tools etc.",
|
||||
fingerprint = "39EB57052F8D684514176819D1645F6A0A7BD943DBC31AB101949006AC0BC228"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://thecapslock.gitlab.io/fdroid-patched-apps/fdroid/repo",
|
||||
name = "Patched Apps",
|
||||
description = "A collection of patched applications to provid" +
|
||||
"e better compatibility, privacy etc..",
|
||||
fingerprint = "313D9E6E789FF4E8E2D687AAE31EEF576050003ED67963301821AC6D3763E3AC"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://mobileapp.bitwarden.com/fdroid/repo",
|
||||
name = "Bitwarden",
|
||||
description = "The official repository for Bitwarden. Bitward" +
|
||||
"en is a password manager.",
|
||||
fingerprint = "BC54EA6FD1CD5175BCCCC47C561C5726E1C3ED7E686B6DB4B18BAC843A3EFE6C"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://briarproject.org/fdroid/repo",
|
||||
name = "Briar",
|
||||
description = "The official repository for Briar. Briar is a" +
|
||||
" serverless/offline messenger that focused on privacy, s" +
|
||||
"ecurity, and decentralization.",
|
||||
fingerprint = "1FB874BEE7276D28ECB2C9B06E8A122EC4BCB4008161436CE474C257CBF49BD6"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://guardianproject-wind.s3.amazonaws.com/fdroid/repo",
|
||||
name = "Wind Project",
|
||||
description = "A collection of interesting offline/serverless apps.",
|
||||
fingerprint = "182CF464D219D340DA443C62155198E399FEC1BC4379309B775DD9FC97ED97E1"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://nanolx.org/fdroid/repo",
|
||||
name = "NanoDroid",
|
||||
description = "A companion repository to microG's installer.",
|
||||
fingerprint = "862ED9F13A3981432BF86FE93D14596B381D75BE83A1D616E2D44A12654AD015"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://releases.threema.ch/fdroid/repo",
|
||||
name = "Threema Libre",
|
||||
description = "The official repository for Threema Libre. R" +
|
||||
"equires Threema Shop license. Threema Libre is an open" +
|
||||
"-source messanger focused on security and privacy.",
|
||||
fingerprint = "5734E753899B25775D90FE85362A49866E05AC4F83C05BEF5A92880D2910639E"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.getsession.org/fdroid/repo",
|
||||
name = "Session",
|
||||
description = "The official repository for Session. Session" +
|
||||
" is an open-source messanger focused on security and privacy.",
|
||||
fingerprint = "DB0E5297EB65CC22D6BD93C869943BDCFCB6A07DC69A48A0DD8C7BA698EC04E6"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://www.cromite.org/fdroid/repo",
|
||||
name = "Cromite",
|
||||
description = "The official repository for Cromite. Cromite" +
|
||||
" is a Chromium with ad blocking and enhanced privacy.",
|
||||
fingerprint = "49F37E74DEE483DCA2B991334FB5A0200787430D0B5F9A783DD5F13695E9517B"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.twinhelix.com/fdroid/repo",
|
||||
name = "TwinHelix",
|
||||
description = "TwinHelix F-Droid Repository, used for Signa" +
|
||||
"l-FOSS, an open-source fork of Signal Private Messenger.",
|
||||
fingerprint = "7b03b0232209b21b10a30a63897d3c6bca4f58fe29bc3477e8e3d8cf8e304028"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.typeblog.net",
|
||||
name = "PeterCxy's F-Droid",
|
||||
description = "You have landed on PeterCxy's F-Droid repo. T" +
|
||||
"o use this repository, please add the page's URL to your F-Droid client.",
|
||||
fingerprint = "1a7e446c491c80bc2f83844a26387887990f97f2f379ae7b109679feae3dbc8c"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://s2.spiritcroc.de/fdroid/repo",
|
||||
name = "SpiritCroc.de",
|
||||
description = "While some of my apps are available from" +
|
||||
" the official F-Droid repository, I also maintain my" +
|
||||
" own repository for a small selection of apps. These" +
|
||||
" might be forks of other apps with only minor change" +
|
||||
"s, or apps that are not published on the Play Store f" +
|
||||
"or other reasons. In contrast to the official F-Droid" +
|
||||
" repos, these might also include proprietary librarie" +
|
||||
"s, e.g. for push notifications.",
|
||||
fingerprint = "6612ade7e93174a589cf5ba26ed3ab28231a789640546c8f30375ef045bc9242"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://s2.spiritcroc.de/testing/fdroid/repo",
|
||||
name = "SpiritCroc.de Test Builds",
|
||||
description = "SpiritCroc.de Test Builds",
|
||||
fingerprint = "52d03f2fab785573bb295c7ab270695e3a1bdd2adc6a6de8713250b33f231225"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://static.cryptomator.org/android/fdroid/repo",
|
||||
name = "Cryptomator",
|
||||
description = "No Description",
|
||||
fingerprint = "f7c3ec3b0d588d3cb52983e9eb1a7421c93d4339a286398e71d7b651e8d8ecdd"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://divestos.org/apks/unofficial/fdroid/repo",
|
||||
name = "DivestOS Unofficial",
|
||||
description = "This repository contains unofficial builds of open source apps" +
|
||||
" that are not included in the other repos.",
|
||||
fingerprint = "a18cdb92f40ebfbbf778a54fd12dbd74d90f1490cb9ef2cc6c7e682dd556855d"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://cdn.kde.org/android/stable-releases/fdroid/repo",
|
||||
name = "KDE Stables",
|
||||
description = "This repository contains unofficial builds of open source apps" +
|
||||
" that are not included in the other repos.",
|
||||
fingerprint = "13784ba6c80ff4e2181e55c56f961eed5844cea16870d3b38d58780b85e1158f"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://zimbelstern.eu/fdroid/repo",
|
||||
name = "Zimbelstern's F-Droid repository",
|
||||
description = "This is the official repository of apps from zimbelstern.eu," +
|
||||
" to be used with F-Droid.",
|
||||
fingerprint = "285158DECEF37CB8DE7C5AF14818ACBF4A9B1FBE63116758EFC267F971CA23AA"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://app.simplex.chat/fdroid/repo",
|
||||
name = "SimpleX Chat F-Droid",
|
||||
description = "SimpleX Chat official F-Droid repository.",
|
||||
fingerprint = "9F358FF284D1F71656A2BFAF0E005DEAE6AA14143720E089F11FF2DDCFEB01BA"
|
||||
)
|
||||
)
|
||||
|
||||
val newlyAdded = listOf<Repository>(
|
||||
defaultRepository(
|
||||
address = "https://f-droid.monerujo.io/fdroid/repo",
|
||||
name = "Monerujo Wallet",
|
||||
description = "Monerujo Monero Wallet official F-Droid repository.",
|
||||
fingerprint = "A82C68E14AF0AA6A2EC20E6B272EFF25E5A038F3F65884316E0F5E0D91E7B713"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.cakelabs.com/fdroid/repo",
|
||||
name = "Cake Labs",
|
||||
description = "Cake Labs official F-Droid repository for Cake Wallet and Monero.com",
|
||||
fingerprint = "EA44EFAEE0B641EE7A032D397D5D976F9C4E5E1ED26E11C75702D064E55F8755"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://app.futo.org/fdroid/repo",
|
||||
name = "FUTO",
|
||||
description = "FUTO official F-Droid repository.",
|
||||
fingerprint = "39D47869D29CBFCE4691D9F7E6946A7B6D7E6FF4883497E6E675744ECDFA6D6D"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.mm20.de/repo",
|
||||
name = "MM20 Apps",
|
||||
description = "Apps developed and distributed by MM20",
|
||||
fingerprint = "156FBAB952F6996415F198F3F29628D24B30E725B0F07A2B49C3A9B5161EEE1A"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://breezy-weather.github.io/fdroid-repo/fdroid/repo",
|
||||
name = "Breezy Weather",
|
||||
description = "The F-Droid repository for Breezy Weather",
|
||||
fingerprint = "3480A7BB2A296D8F98CB90D2309199B5B9519C1B31978DBCD877ADB102AF35EE"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://gh.artemchep.com/keyguard-repo-fdroid/repo",
|
||||
name = "Keyguard Project",
|
||||
description = "Mirrors artifacts available on https://github.com/AChep/keyguard-app/releases",
|
||||
fingerprint = "03941CE79B081666609C8A48AB6E46774263F6FC0BBF1FA046CCFFC60EA643BC"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://f5a.torus.icu/fdroid/repo",
|
||||
name = "Fcitx 5 For Android F-Droid Repo",
|
||||
description = "Out-of-tree fcitx5-android plugins.",
|
||||
fingerprint = "5D87CE1FAD3772425C2A7ED987A57595A20B07543B9595A7FD2CED25DFF3CF12"
|
||||
),
|
||||
defaultRepository(
|
||||
address = "https://fdroid.i2pd.xyz/fdroid/repo/",
|
||||
name = "PurpleI2P F-Droid repository",
|
||||
description = "This is a repository of PurpleI2P. It contains applications developed and supported by our team.",
|
||||
fingerprint = "5D87CE1FAD3772425C2A7ED987A57595A20B07543B9595A7FD2CED25DFF3CF12"
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.looker.droidify.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import com.looker.core.common.extension.getPackageInfoCompat
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.utility.extension.toInstalledItem
|
||||
|
||||
class InstalledAppReceiver(private val packageManager: PackageManager) : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val packageName =
|
||||
intent.data?.let { if (it.scheme == "package") it.schemeSpecificPart else null }
|
||||
if (packageName != null) {
|
||||
when (intent.action.orEmpty()) {
|
||||
Intent.ACTION_PACKAGE_ADDED,
|
||||
Intent.ACTION_PACKAGE_REMOVED
|
||||
-> {
|
||||
val packageInfo = packageManager.getPackageInfoCompat(packageName)
|
||||
if (packageInfo != null) {
|
||||
Database.InstalledAdapter.put(packageInfo.toInstalledItem())
|
||||
} else {
|
||||
Database.InstalledAdapter.delete(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.looker.droidify.service
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
|
||||
class Connection<B : IBinder, S : ConnectionService<B>>(
|
||||
private val serviceClass: Class<S>,
|
||||
private val onBind: ((Connection<B, S>, B) -> Unit)? = null,
|
||||
private val onUnbind: ((Connection<B, S>, B) -> Unit)? = null
|
||||
) : ServiceConnection {
|
||||
var binder: B? = null
|
||||
private set
|
||||
|
||||
private fun handleUnbind() {
|
||||
binder?.let {
|
||||
binder = null
|
||||
onUnbind?.invoke(this, it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
binder as B
|
||||
this.binder = binder
|
||||
onBind?.invoke(this, binder)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(componentName: ComponentName) {
|
||||
handleUnbind()
|
||||
}
|
||||
|
||||
fun bind(context: Context) {
|
||||
context.bindService(Intent(context, serviceClass), this, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
fun unbind(context: Context) {
|
||||
context.unbindService(this)
|
||||
handleUnbind()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.looker.droidify.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
|
||||
abstract class ConnectionService<T : IBinder> : Service() {
|
||||
|
||||
private val supervisorJob = SupervisorJob()
|
||||
val lifecycleScope = CoroutineScope(Dispatchers.Main + supervisorJob)
|
||||
|
||||
abstract override fun onBind(intent: Intent): T
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
lifecycleScope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package com.looker.droidify.service
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.looker.core.common.Constants
|
||||
import com.looker.core.common.Constants.NOTIFICATION_CHANNEL_INSTALL
|
||||
import com.looker.core.common.R
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.common.createNotificationChannel
|
||||
import com.looker.core.common.extension.notificationManager
|
||||
import com.looker.core.common.extension.percentBy
|
||||
import com.looker.core.common.extension.startSelf
|
||||
import com.looker.core.common.extension.stopForegroundCompat
|
||||
import com.looker.core.common.extension.toPendingIntent
|
||||
import com.looker.core.common.extension.updateAsMutable
|
||||
import com.looker.core.common.log
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.core.datastore.model.InstallerType
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.MainActivity
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.installer.InstallManager
|
||||
import com.looker.installer.model.InstallState
|
||||
import com.looker.installer.model.installFrom
|
||||
import com.looker.installer.notification.createInstallNotification
|
||||
import com.looker.installer.notification.installNotification
|
||||
import com.looker.network.DataSize
|
||||
import com.looker.network.Downloader
|
||||
import com.looker.network.NetworkResponse
|
||||
import com.looker.network.validation.ValidationException
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.yield
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DownloadService : ConnectionService<DownloadService.Binder>() {
|
||||
companion object {
|
||||
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var downloader: Downloader
|
||||
|
||||
private val installerType
|
||||
get() = settingsRepository.get { installerType }
|
||||
|
||||
@Inject
|
||||
lateinit var installer: InstallManager
|
||||
|
||||
sealed class State(val packageName: String) {
|
||||
data object Idle : State("")
|
||||
data class Connecting(val name: String) : State(name)
|
||||
data class Downloading(val name: String, val read: DataSize, val total: DataSize?) : State(
|
||||
name
|
||||
)
|
||||
|
||||
data class Error(val name: String) : State(name)
|
||||
data class Cancel(val name: String) : State(name)
|
||||
data class Success(val name: String, val release: Release) : State(name)
|
||||
}
|
||||
|
||||
data class DownloadState(
|
||||
val currentItem: State = State.Idle,
|
||||
val queue: List<String> = emptyList()
|
||||
) {
|
||||
infix fun isDownloading(packageName: String): Boolean =
|
||||
currentItem.packageName == packageName && (
|
||||
currentItem is State.Connecting || currentItem is State.Downloading
|
||||
)
|
||||
|
||||
infix fun isComplete(packageName: String): Boolean =
|
||||
currentItem.packageName == packageName && (
|
||||
currentItem is State.Error ||
|
||||
currentItem is State.Cancel ||
|
||||
currentItem is State.Success ||
|
||||
currentItem is State.Idle
|
||||
)
|
||||
}
|
||||
|
||||
private val _downloadState = MutableStateFlow(DownloadState())
|
||||
|
||||
private class Task(
|
||||
val packageName: String,
|
||||
val name: String,
|
||||
val release: Release,
|
||||
val url: String,
|
||||
val authentication: String,
|
||||
val isUpdate: Boolean = false
|
||||
) {
|
||||
val notificationTag: String
|
||||
get() = "download-$packageName"
|
||||
}
|
||||
|
||||
private data class CurrentTask(val task: Task, val job: Job, val lastState: State)
|
||||
|
||||
private var started = false
|
||||
private val tasks = mutableListOf<Task>()
|
||||
private var currentTask: CurrentTask? = null
|
||||
|
||||
private val lock = Mutex()
|
||||
|
||||
inner class Binder : android.os.Binder() {
|
||||
val downloadState = _downloadState.asStateFlow()
|
||||
fun enqueue(
|
||||
packageName: String,
|
||||
name: String,
|
||||
repository: Repository,
|
||||
release: Release,
|
||||
isUpdate: Boolean = false
|
||||
) {
|
||||
val task = Task(
|
||||
packageName = packageName,
|
||||
name = name,
|
||||
release = release,
|
||||
url = release.getDownloadUrl(repository),
|
||||
authentication = repository.authentication,
|
||||
isUpdate = isUpdate
|
||||
)
|
||||
if (Cache.getReleaseFile(this@DownloadService, release.cacheFileName).exists()) {
|
||||
lifecycleScope.launch { publishSuccess(task) }
|
||||
return
|
||||
}
|
||||
cancelTasks(packageName)
|
||||
cancelCurrentTask(packageName)
|
||||
notificationManager?.cancel(
|
||||
task.notificationTag,
|
||||
Constants.NOTIFICATION_ID_DOWNLOADING
|
||||
)
|
||||
tasks += task
|
||||
if (currentTask == null) {
|
||||
handleDownload()
|
||||
} else {
|
||||
updateCurrentQueue { add(packageName) }
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(packageName: String) {
|
||||
cancelTasks(packageName)
|
||||
cancelCurrentTask(packageName)
|
||||
}
|
||||
}
|
||||
|
||||
private val binder = Binder()
|
||||
override fun onBind(intent: Intent): Binder = binder
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel(
|
||||
id = Constants.NOTIFICATION_CHANNEL_DOWNLOADING,
|
||||
name = getString(stringRes.downloading),
|
||||
)
|
||||
createNotificationChannel(
|
||||
id = NOTIFICATION_CHANNEL_INSTALL,
|
||||
name = getString(R.string.install)
|
||||
)
|
||||
|
||||
lifecycleScope.launch {
|
||||
_downloadState
|
||||
.filter { currentTask != null }
|
||||
.sample(400)
|
||||
.collectLatest {
|
||||
publishForegroundState(false, it.currentItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTimeout(startId: Int) {
|
||||
super.onTimeout(startId)
|
||||
onDestroy()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
cancelTasks(null)
|
||||
cancelCurrentTask(null)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_CANCEL) {
|
||||
currentTask?.let { binder.cancel(it.task.packageName) }
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun cancelTasks(packageName: String?) {
|
||||
tasks.removeAll {
|
||||
(packageName == null || it.packageName == packageName) && run {
|
||||
updateCurrentState(State.Cancel(it.packageName))
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelCurrentTask(packageName: String?) {
|
||||
currentTask?.let {
|
||||
if (packageName == null || it.task.packageName == packageName) {
|
||||
it.job.cancel()
|
||||
currentTask = null
|
||||
updateCurrentState(State.Cancel(it.task.packageName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface ErrorType {
|
||||
data object IO : ErrorType
|
||||
data object Http : ErrorType
|
||||
data object SocketTimeout : ErrorType
|
||||
data object ConnectionTimeout : ErrorType
|
||||
class Validation(val exception: ValidationException) : ErrorType
|
||||
}
|
||||
|
||||
private fun showNotificationError(task: Task, errorType: ErrorType) {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
.setAction(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse("package:${task.packageName}"))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
.toPendingIntent(this)
|
||||
notificationManager?.notify(
|
||||
task.notificationTag,
|
||||
Constants.NOTIFICATION_ID_DOWNLOADING,
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_DOWNLOADING)
|
||||
.setAutoCancel(true)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setColor(Color.GREEN)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(intent)
|
||||
.errorNotificationContent(task, errorType)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun NotificationCompat.Builder.errorNotificationContent(
|
||||
task: Task,
|
||||
errorType: ErrorType
|
||||
): NotificationCompat.Builder {
|
||||
val title = if (errorType is ErrorType.Validation) {
|
||||
stringRes.could_not_validate_FORMAT
|
||||
} else {
|
||||
stringRes.could_not_download_FORMAT
|
||||
}
|
||||
val description = when (errorType) {
|
||||
ErrorType.ConnectionTimeout -> getString(stringRes.connection_error_DESC)
|
||||
ErrorType.Http -> getString(stringRes.http_error_DESC)
|
||||
ErrorType.IO -> getString(stringRes.io_error_DESC)
|
||||
ErrorType.SocketTimeout -> getString(stringRes.socket_error_DESC)
|
||||
is ErrorType.Validation -> errorType.exception.message
|
||||
}
|
||||
setContentTitle(getString(title, task.name))
|
||||
return setContentText(description)
|
||||
}
|
||||
|
||||
private fun showNotificationInstall(task: Task) {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
.setAction(MainActivity.ACTION_INSTALL)
|
||||
.setData(Uri.parse("package:${task.packageName}"))
|
||||
.putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, task.release.cacheFileName)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
.toPendingIntent(this)
|
||||
val notification = createInstallNotification(
|
||||
appName = task.name,
|
||||
state = InstallState.Pending,
|
||||
autoCancel = true,
|
||||
) {
|
||||
setContentIntent(intent)
|
||||
}
|
||||
notificationManager?.installNotification(
|
||||
packageName = task.packageName,
|
||||
notification = notification,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun publishSuccess(task: Task) {
|
||||
val currentInstaller = installerType.first()
|
||||
updateCurrentQueue { add("") }
|
||||
updateCurrentState(State.Success(task.packageName, task.release))
|
||||
val autoInstallWithSessionInstaller =
|
||||
SdkCheck.canAutoInstall(task.release.targetSdkVersion) &&
|
||||
currentInstaller == InstallerType.SESSION &&
|
||||
task.isUpdate
|
||||
|
||||
showNotificationInstall(task)
|
||||
if (currentInstaller == InstallerType.ROOT ||
|
||||
currentInstaller == InstallerType.SHIZUKU ||
|
||||
autoInstallWithSessionInstaller
|
||||
) {
|
||||
val installItem = task.packageName installFrom task.release.cacheFileName
|
||||
installer install installItem
|
||||
}
|
||||
}
|
||||
|
||||
private val stateNotificationBuilder by lazy {
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_DOWNLOADING)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setColor(Color.GREEN)
|
||||
.addAction(
|
||||
0,
|
||||
getString(stringRes.cancel),
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
0,
|
||||
Intent(this, this::class.java).setAction(ACTION_CANCEL),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun publishForegroundState(force: Boolean, state: State) {
|
||||
if (!force && currentTask == null) return
|
||||
currentTask = currentTask!!.copy(lastState = state)
|
||||
stateNotificationBuilder.downloadingNotificationContent(state)
|
||||
?.let { notification ->
|
||||
startForeground(
|
||||
Constants.NOTIFICATION_ID_DOWNLOADING,
|
||||
notification.build()
|
||||
)
|
||||
} ?: run {
|
||||
log("Invalid Download State: $state", "DownloadService", Log.ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationCompat.Builder.downloadingNotificationContent(
|
||||
state: State
|
||||
): NotificationCompat.Builder? {
|
||||
return when (state) {
|
||||
is State.Connecting -> {
|
||||
setContentTitle(getString(stringRes.downloading_FORMAT, currentTask!!.task.name))
|
||||
setContentText(getString(stringRes.connecting))
|
||||
setProgress(1, 0, true)
|
||||
}
|
||||
|
||||
is State.Downloading -> {
|
||||
setContentTitle(getString(stringRes.downloading_FORMAT, currentTask!!.task.name))
|
||||
if (state.total != null) {
|
||||
setContentText("${state.read} / ${state.total}")
|
||||
setProgress(100, state.read.value percentBy state.total.value, false)
|
||||
} else {
|
||||
setContentText(state.read.toString())
|
||||
setProgress(0, 0, true)
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDownload() {
|
||||
if (currentTask != null) return
|
||||
if (tasks.isEmpty() && started) {
|
||||
started = false
|
||||
stopForegroundCompat()
|
||||
return
|
||||
}
|
||||
if (!started) {
|
||||
started = true
|
||||
startSelf()
|
||||
}
|
||||
val task = tasks.removeFirstOrNull() ?: return
|
||||
with(stateNotificationBuilder) {
|
||||
setWhen(System.currentTimeMillis())
|
||||
setContentIntent(createNotificationIntent(task.packageName))
|
||||
}
|
||||
val connectionState = State.Connecting(task.packageName)
|
||||
val partialReleaseFile =
|
||||
Cache.getPartialReleaseFile(this, task.release.cacheFileName)
|
||||
val job = lifecycleScope.downloadFile(task, partialReleaseFile)
|
||||
currentTask = CurrentTask(task, job, connectionState)
|
||||
publishForegroundState(true, connectionState)
|
||||
updateCurrentState(State.Connecting(task.packageName))
|
||||
}
|
||||
|
||||
private fun createNotificationIntent(packageName: String): PendingIntent? =
|
||||
Intent(this, MainActivity::class.java)
|
||||
.setAction(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse("package:$packageName"))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.toPendingIntent(this)
|
||||
|
||||
private fun CoroutineScope.downloadFile(
|
||||
task: Task,
|
||||
target: File
|
||||
) = launch {
|
||||
try {
|
||||
val releaseValidator = ReleaseFileValidator(
|
||||
context = this@DownloadService,
|
||||
packageName = task.packageName,
|
||||
release = task.release
|
||||
)
|
||||
val response = downloader.downloadToFile(
|
||||
url = task.url,
|
||||
target = target,
|
||||
validator = releaseValidator,
|
||||
headers = { authentication(task.authentication) }
|
||||
) { read, total ->
|
||||
yield()
|
||||
updateCurrentState(State.Downloading(task.packageName, read, total))
|
||||
}
|
||||
|
||||
when (response) {
|
||||
is NetworkResponse.Success -> {
|
||||
val releaseFile = Cache.getReleaseFile(
|
||||
this@DownloadService,
|
||||
task.release.cacheFileName
|
||||
)
|
||||
target.renameTo(releaseFile)
|
||||
publishSuccess(task)
|
||||
}
|
||||
|
||||
is NetworkResponse.Error -> {
|
||||
updateCurrentState(State.Error(task.packageName))
|
||||
val errorType = when (response) {
|
||||
is NetworkResponse.Error.ConnectionTimeout -> ErrorType.ConnectionTimeout
|
||||
is NetworkResponse.Error.IO -> ErrorType.IO
|
||||
is NetworkResponse.Error.SocketTimeout -> ErrorType.SocketTimeout
|
||||
is NetworkResponse.Error.Validation -> ErrorType.Validation(
|
||||
response.exception
|
||||
)
|
||||
|
||||
else -> ErrorType.Http
|
||||
}
|
||||
showNotificationError(task, errorType)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
lock.withLock { currentTask = null }
|
||||
handleDownload()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCurrentState(state: State) {
|
||||
_downloadState.update {
|
||||
val newQueue =
|
||||
if (state.packageName in it.queue) {
|
||||
it.queue.updateAsMutable {
|
||||
removeAll { name -> name == "" }
|
||||
remove(state.packageName)
|
||||
}
|
||||
} else {
|
||||
it.queue
|
||||
}
|
||||
it.copy(currentItem = state, queue = newQueue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCurrentQueue(block: MutableList<String>.() -> Unit) {
|
||||
_downloadState.update { state ->
|
||||
state.copy(queue = state.queue.updateAsMutable(block))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.looker.droidify.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import com.looker.core.common.extension.calculateHash
|
||||
import com.looker.core.common.extension.getPackageArchiveInfoCompat
|
||||
import com.looker.core.common.extension.singleSignature
|
||||
import com.looker.core.common.extension.versionCodeCompat
|
||||
import com.looker.network.validation.FileValidator
|
||||
import com.looker.core.common.signature.Hash
|
||||
import com.looker.network.validation.invalid
|
||||
import com.looker.core.common.signature.verifyHash
|
||||
import com.looker.droidify.model.Release
|
||||
import java.io.File
|
||||
import com.looker.core.common.R.string as strings
|
||||
|
||||
class ReleaseFileValidator(
|
||||
private val context: Context,
|
||||
private val packageName: String,
|
||||
private val release: Release
|
||||
) : FileValidator {
|
||||
|
||||
override suspend fun validate(file: File) {
|
||||
val hash = Hash(release.hashType, release.hash)
|
||||
if (!file.verifyHash(hash)) {
|
||||
invalid(getString(strings.integrity_check_error_DESC))
|
||||
}
|
||||
val packageInfo = context.packageManager.getPackageArchiveInfoCompat(file.path)
|
||||
?: invalid(getString(strings.file_format_error_DESC))
|
||||
if (packageInfo.packageName != packageName ||
|
||||
packageInfo.versionCodeCompat != release.versionCode
|
||||
) {
|
||||
invalid(getString(strings.invalid_metadata_error_DESC))
|
||||
}
|
||||
|
||||
packageInfo.singleSignature
|
||||
?.calculateHash()
|
||||
?.takeIf { it.isNotBlank() || it == release.signature }
|
||||
?: invalid(getString(strings.invalid_signature_error_DESC))
|
||||
|
||||
packageInfo.permissions
|
||||
?.asSequence()
|
||||
.orEmpty()
|
||||
.map { it.name }
|
||||
.toSet()
|
||||
.takeIf { release.permissions.containsAll(it) }
|
||||
?: invalid(getString(strings.invalid_permissions_error_DESC))
|
||||
}
|
||||
|
||||
private fun getString(@StringRes id: Int): String = context.getString(id)
|
||||
}
|
||||
645
app/src/main/kotlin/com/looker/droidify/service/SyncService.kt
Normal file
645
app/src/main/kotlin/com/looker/droidify/service/SyncService.kt
Normal file
@@ -0,0 +1,645 @@
|
||||
package com.looker.droidify.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.app.job.JobInfo
|
||||
import android.app.job.JobParameters
|
||||
import android.app.job.JobService
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.view.ContextThemeWrapper
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.looker.core.common.Constants
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.createNotificationChannel
|
||||
import com.looker.core.common.extension.getColorFromAttr
|
||||
import com.looker.core.common.extension.notificationManager
|
||||
import com.looker.core.common.extension.startSelf
|
||||
import com.looker.core.common.extension.stopForegroundCompat
|
||||
import com.looker.core.common.log
|
||||
import com.looker.core.common.result.Result
|
||||
import com.looker.core.common.sdkAbove
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.MainActivity
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.index.RepositoryUpdater
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.utility.extension.startUpdate
|
||||
import com.looker.network.DataSize
|
||||
import com.looker.network.percentBy
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
import com.looker.core.common.R as CommonR
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
import com.looker.core.common.R.style as styleRes
|
||||
import kotlinx.coroutines.Job as CoroutinesJob
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SyncService : ConnectionService<SyncService.Binder>() {
|
||||
|
||||
companion object {
|
||||
private const val MAX_PROGRESS = 100
|
||||
|
||||
private const val NOTIFICATION_UPDATE_SAMPLING = 400L
|
||||
|
||||
private const val MAX_UPDATE_NOTIFICATION = 5
|
||||
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
|
||||
|
||||
private val syncState = MutableSharedFlow<State>()
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
sealed class State(val name: String) {
|
||||
data class Connecting(val appName: String) : State(appName)
|
||||
data class Syncing(
|
||||
val appName: String,
|
||||
val stage: RepositoryUpdater.Stage,
|
||||
val read: DataSize,
|
||||
val total: DataSize?
|
||||
) : State(appName)
|
||||
|
||||
data object Finish : State("")
|
||||
}
|
||||
|
||||
private class Task(val repositoryId: Long, val manual: Boolean)
|
||||
private data class CurrentTask(
|
||||
val task: Task?,
|
||||
val job: CoroutinesJob,
|
||||
val hasUpdates: Boolean,
|
||||
val lastState: State
|
||||
)
|
||||
|
||||
private enum class Started { NO, AUTO, MANUAL }
|
||||
|
||||
private var started = Started.NO
|
||||
private val tasks = mutableListOf<Task>()
|
||||
private var currentTask: CurrentTask? = null
|
||||
|
||||
private var updateNotificationBlockerFragment: WeakReference<Fragment>? = null
|
||||
|
||||
private val downloadConnection = Connection(DownloadService::class.java)
|
||||
private val lock = Mutex()
|
||||
|
||||
enum class SyncRequest { AUTO, MANUAL, FORCE }
|
||||
|
||||
inner class Binder : android.os.Binder() {
|
||||
|
||||
val state: SharedFlow<State>
|
||||
get() = syncState.asSharedFlow()
|
||||
|
||||
private fun sync(ids: List<Long>, request: SyncRequest) {
|
||||
val cancelledTask =
|
||||
cancelCurrentTask { request == SyncRequest.FORCE && it.task?.repositoryId in ids }
|
||||
cancelTasks { !it.manual && it.repositoryId in ids }
|
||||
val currentIds = tasks.asSequence().map { it.repositoryId }.toSet()
|
||||
val manual = request != SyncRequest.AUTO
|
||||
tasks += ids.asSequence().filter {
|
||||
it !in currentIds &&
|
||||
it != currentTask?.task?.repositoryId
|
||||
}.map { Task(it, manual) }
|
||||
handleNextTask(cancelledTask?.hasUpdates == true)
|
||||
if (request != SyncRequest.AUTO && started == Started.AUTO) {
|
||||
started = Started.MANUAL
|
||||
startSelf()
|
||||
handleSetStarted()
|
||||
currentTask?.lastState?.let { publishForegroundState(true, it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun sync(request: SyncRequest) {
|
||||
val ids = Database.RepositoryAdapter.getAll()
|
||||
.asSequence().filter { it.enabled }.map { it.id }.toList()
|
||||
sync(ids, request)
|
||||
}
|
||||
|
||||
fun sync(repository: Repository) {
|
||||
if (repository.enabled) {
|
||||
sync(listOf(repository.id), SyncRequest.FORCE)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateAllApps() {
|
||||
updateAllAppsInternal()
|
||||
}
|
||||
|
||||
fun setUpdateNotificationBlocker(fragment: Fragment?) {
|
||||
updateNotificationBlockerFragment = fragment?.let(::WeakReference)
|
||||
if (fragment != null) {
|
||||
notificationManager?.cancel(Constants.NOTIFICATION_ID_UPDATES)
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(repository: Repository, enabled: Boolean): Boolean {
|
||||
Database.RepositoryAdapter.put(repository.enable(enabled))
|
||||
if (enabled) {
|
||||
val isRepoInTasks = repository.id != currentTask?.task?.repositoryId &&
|
||||
!tasks.any { it.repositoryId == repository.id }
|
||||
if (isRepoInTasks) {
|
||||
tasks += Task(repository.id, true)
|
||||
handleNextTask(false)
|
||||
}
|
||||
} else {
|
||||
cancelTasks { it.repositoryId == repository.id }
|
||||
val cancelledTask = cancelCurrentTask {
|
||||
it.task?.repositoryId == repository.id
|
||||
}
|
||||
handleNextTask(cancelledTask?.hasUpdates == true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun isCurrentlySyncing(repositoryId: Long): Boolean {
|
||||
return currentTask?.task?.repositoryId == repositoryId
|
||||
}
|
||||
|
||||
fun deleteRepository(repositoryId: Long): Boolean {
|
||||
val repository = Database.RepositoryAdapter.get(repositoryId)
|
||||
return repository != null && run {
|
||||
setEnabled(repository, false)
|
||||
Database.RepositoryAdapter.markAsDeleted(repository.id)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAuto(): Boolean {
|
||||
val removed = cancelTasks { !it.manual }
|
||||
val currentTask = cancelCurrentTask { it.task?.manual == false }
|
||||
handleNextTask(currentTask?.hasUpdates == true)
|
||||
return removed || currentTask != null
|
||||
}
|
||||
}
|
||||
|
||||
private val binder = Binder()
|
||||
override fun onBind(intent: Intent): Binder = binder
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
createNotificationChannel(
|
||||
id = Constants.NOTIFICATION_CHANNEL_SYNCING,
|
||||
name = getString(stringRes.syncing),
|
||||
)
|
||||
createNotificationChannel(
|
||||
id = Constants.NOTIFICATION_CHANNEL_UPDATES,
|
||||
name = getString(stringRes.updates),
|
||||
)
|
||||
|
||||
downloadConnection.bind(this)
|
||||
lifecycleScope.launch {
|
||||
syncState
|
||||
.sample(NOTIFICATION_UPDATE_SAMPLING)
|
||||
.collectLatest {
|
||||
publishForegroundState(false, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTimeout(startId: Int) {
|
||||
super.onTimeout(startId)
|
||||
onDestroy()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
downloadConnection.unbind(this)
|
||||
cancelTasks { true }
|
||||
cancelCurrentTask { true }
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_CANCEL) {
|
||||
tasks.clear()
|
||||
val cancelledTask = cancelCurrentTask { it.task != null }
|
||||
handleNextTask(cancelledTask?.hasUpdates == true)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun cancelTasks(condition: (Task) -> Boolean): Boolean {
|
||||
return tasks.removeAll(condition)
|
||||
}
|
||||
|
||||
private fun cancelCurrentTask(condition: ((CurrentTask) -> Boolean)): CurrentTask? {
|
||||
return currentTask?.let {
|
||||
if (condition(it)) {
|
||||
currentTask = null
|
||||
it.job.cancel()
|
||||
RepositoryUpdater.await()
|
||||
it
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotificationError(repository: Repository, exception: Exception) {
|
||||
val description = getString(
|
||||
when (exception) {
|
||||
is RepositoryUpdater.UpdateException -> when (exception.errorType) {
|
||||
RepositoryUpdater.ErrorType.NETWORK -> stringRes.network_error_DESC
|
||||
RepositoryUpdater.ErrorType.HTTP -> stringRes.http_error_DESC
|
||||
RepositoryUpdater.ErrorType.VALIDATION -> stringRes.validation_index_error_DESC
|
||||
RepositoryUpdater.ErrorType.PARSING -> stringRes.parsing_index_error_DESC
|
||||
}
|
||||
|
||||
else -> stringRes.unknown_error_DESC
|
||||
}
|
||||
)
|
||||
notificationManager?.notify(
|
||||
"repository-${repository.id}",
|
||||
Constants.NOTIFICATION_ID_SYNCING,
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
|
||||
.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.setContentTitle(getString(stringRes.could_not_sync_FORMAT, repository.name))
|
||||
.setContentText(description)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private val stateNotificationBuilder by lazy {
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_SYNCING)
|
||||
.setSmallIcon(CommonR.drawable.ic_sync)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
|
||||
.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.addAction(
|
||||
0,
|
||||
getString(stringRes.cancel),
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
0,
|
||||
Intent(this, this::class.java).setAction(ACTION_CANCEL),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun publishForegroundState(force: Boolean, state: State) {
|
||||
if (force || currentTask?.lastState != state) {
|
||||
currentTask = currentTask?.copy(lastState = state)
|
||||
if (started == Started.MANUAL) {
|
||||
startForeground(
|
||||
Constants.NOTIFICATION_ID_SYNCING,
|
||||
stateNotificationBuilder.apply {
|
||||
setContentTitle(getString(stringRes.syncing_FORMAT, state.name))
|
||||
when (state) {
|
||||
is State.Connecting -> {
|
||||
setContentText(getString(stringRes.connecting))
|
||||
setProgress(0, 0, true)
|
||||
}
|
||||
|
||||
is State.Syncing -> {
|
||||
when (state.stage) {
|
||||
RepositoryUpdater.Stage.DOWNLOAD -> {
|
||||
if (state.total != null) {
|
||||
setContentText("${state.read} / ${state.total}")
|
||||
setProgress(
|
||||
MAX_PROGRESS,
|
||||
state.read percentBy state.total,
|
||||
false
|
||||
)
|
||||
} else {
|
||||
setContentText(state.read.toString())
|
||||
setProgress(0, 0, true)
|
||||
}
|
||||
}
|
||||
|
||||
RepositoryUpdater.Stage.PROCESS -> {
|
||||
val progress = (state.read percentBy state.total)
|
||||
.takeIf { it != -1 }
|
||||
setContentText(
|
||||
getString(
|
||||
stringRes.processing_FORMAT,
|
||||
"${progress ?: 0}%"
|
||||
)
|
||||
)
|
||||
setProgress(MAX_PROGRESS, progress ?: 0, progress == null)
|
||||
}
|
||||
|
||||
RepositoryUpdater.Stage.MERGE -> {
|
||||
val progress = (state.read percentBy state.total)
|
||||
setContentText(
|
||||
getString(
|
||||
stringRes.merging_FORMAT,
|
||||
"${state.read.value} / ${state.total?.value ?: state.read.value}"
|
||||
)
|
||||
)
|
||||
setProgress(MAX_PROGRESS, progress, false)
|
||||
}
|
||||
|
||||
RepositoryUpdater.Stage.COMMIT -> {
|
||||
setContentText(getString(stringRes.saving_details))
|
||||
setProgress(0, 0, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is State.Finish -> {}
|
||||
}::class
|
||||
}.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetStarted() {
|
||||
stateNotificationBuilder.setWhen(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
private fun handleNextTask(hasUpdates: Boolean) {
|
||||
if (currentTask != null) return
|
||||
if (tasks.isEmpty()) {
|
||||
if (started != Started.NO) {
|
||||
lifecycleScope.launch {
|
||||
val setting = settingsRepository.getInitial()
|
||||
handleUpdates(
|
||||
hasUpdates = hasUpdates,
|
||||
notifyUpdates = setting.notifyUpdate,
|
||||
autoUpdate = setting.autoUpdate
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
val task = tasks.removeAt(0)
|
||||
val repository = Database.RepositoryAdapter.get(task.repositoryId)
|
||||
if (repository == null || !repository.enabled) handleNextTask(hasUpdates)
|
||||
val lastStarted = started
|
||||
val newStarted = if (task.manual || lastStarted == Started.MANUAL) {
|
||||
Started.MANUAL
|
||||
} else {
|
||||
Started.AUTO
|
||||
}
|
||||
started = newStarted
|
||||
if (newStarted == Started.MANUAL && lastStarted != Started.MANUAL) {
|
||||
startSelf()
|
||||
handleSetStarted()
|
||||
}
|
||||
val initialState = State.Connecting(repository!!.name)
|
||||
publishForegroundState(true, initialState)
|
||||
lifecycleScope.launch {
|
||||
val unstableUpdates =
|
||||
settingsRepository.getInitial().unstableUpdate
|
||||
val downloadJob = downloadFile(
|
||||
task = task,
|
||||
repository = repository,
|
||||
hasUpdates = hasUpdates,
|
||||
unstableUpdates = unstableUpdates
|
||||
)
|
||||
currentTask = CurrentTask(task, downloadJob, hasUpdates, initialState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.downloadFile(
|
||||
task: Task,
|
||||
repository: Repository,
|
||||
hasUpdates: Boolean,
|
||||
unstableUpdates: Boolean
|
||||
): CoroutinesJob = launch(Dispatchers.Default) {
|
||||
var passedHasUpdates = hasUpdates
|
||||
try {
|
||||
val response = RepositoryUpdater.update(
|
||||
this@SyncService,
|
||||
repository,
|
||||
unstableUpdates
|
||||
) { stage, progress, total ->
|
||||
launch {
|
||||
syncState.emit(
|
||||
State.Syncing(
|
||||
appName = repository.name,
|
||||
stage = stage,
|
||||
read = DataSize(progress),
|
||||
total = total?.let { DataSize(it) }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
passedHasUpdates = when (response) {
|
||||
is Result.Error -> {
|
||||
response.exception?.let {
|
||||
it.printStackTrace()
|
||||
if (task.manual) showNotificationError(repository, it as Exception)
|
||||
}
|
||||
response.data == true || hasUpdates
|
||||
}
|
||||
|
||||
is Result.Success -> response.data || hasUpdates
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
lock.withLock { currentTask = null }
|
||||
handleNextTask(passedHasUpdates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleUpdates(
|
||||
hasUpdates: Boolean,
|
||||
notifyUpdates: Boolean,
|
||||
autoUpdate: Boolean
|
||||
) {
|
||||
try {
|
||||
if (!hasUpdates) {
|
||||
syncState.emit(State.Finish)
|
||||
val needStop = started == Started.MANUAL
|
||||
started = Started.NO
|
||||
if (needStop) stopForegroundCompat()
|
||||
return
|
||||
}
|
||||
val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true
|
||||
val updates = Database.ProductAdapter.getUpdates()
|
||||
if (!blocked && updates.isNotEmpty()) {
|
||||
if (notifyUpdates) displayUpdatesNotification(updates)
|
||||
if (autoUpdate) updateAllAppsInternal()
|
||||
}
|
||||
handleUpdates(
|
||||
hasUpdates = false,
|
||||
notifyUpdates = notifyUpdates,
|
||||
autoUpdate = autoUpdate
|
||||
)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
lock.withLock { currentTask = null }
|
||||
handleNextTask(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateAllAppsInternal() {
|
||||
log("Check Running", "Syncing")
|
||||
Database.ProductAdapter
|
||||
.getUpdates()
|
||||
// Update Droid-ify the last
|
||||
.sortedBy { if (it.packageName == packageName) 1 else -1 }
|
||||
.map {
|
||||
Database.InstalledAdapter.get(it.packageName, null) to
|
||||
Database.RepositoryAdapter.get(it.repositoryId)
|
||||
}
|
||||
.filter { it.first != null && it.second != null }
|
||||
.forEach { (installItem, repo) ->
|
||||
val productRepo = Database.ProductAdapter.get(installItem!!.packageName, null)
|
||||
.filter { it.repositoryId == repo!!.id }
|
||||
.map { it to repo!! }
|
||||
downloadConnection.startUpdate(
|
||||
installItem.packageName,
|
||||
installItem,
|
||||
productRepo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayUpdatesNotification(productItems: List<ProductItem>) {
|
||||
fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback)
|
||||
notificationManager?.notify(
|
||||
Constants.NOTIFICATION_ID_UPDATES,
|
||||
NotificationCompat
|
||||
.Builder(this, Constants.NOTIFICATION_CHANNEL_UPDATES)
|
||||
.setSmallIcon(CommonR.drawable.ic_new_releases)
|
||||
.setContentTitle(getString(stringRes.new_updates_available))
|
||||
.setContentText(
|
||||
resources.getQuantityString(
|
||||
CommonR.plurals.new_updates_DESC_FORMAT,
|
||||
productItems.size,
|
||||
productItems.size
|
||||
)
|
||||
)
|
||||
.setColor(
|
||||
ContextThemeWrapper(this, styleRes.Theme_Main_Light)
|
||||
.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
|
||||
)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java)
|
||||
.setAction(MainActivity.ACTION_UPDATES),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
.setStyle(
|
||||
NotificationCompat.InboxStyle().applyHack {
|
||||
for (productItem in productItems.take(MAX_UPDATE_NOTIFICATION)) {
|
||||
val builder = SpannableStringBuilder(productItem.name)
|
||||
builder.setSpan(
|
||||
ForegroundColorSpan(Color.BLACK),
|
||||
0,
|
||||
builder.length,
|
||||
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
builder.append(' ').append(productItem.version)
|
||||
addLine(builder)
|
||||
}
|
||||
if (productItems.size > MAX_UPDATE_NOTIFICATION) {
|
||||
val summary =
|
||||
getString(
|
||||
stringRes.plus_more_FORMAT,
|
||||
productItems.size - MAX_UPDATE_NOTIFICATION
|
||||
)
|
||||
if (SdkCheck.isNougat) addLine(summary) else setSummaryText(summary)
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("SpecifyJobSchedulerIdRange")
|
||||
class Job : JobService() {
|
||||
private val jobScope = CoroutineScope(Dispatchers.Default)
|
||||
private var syncParams: JobParameters? = null
|
||||
private val syncConnection =
|
||||
Connection(SyncService::class.java, onBind = { connection, binder ->
|
||||
jobScope.launch {
|
||||
binder.state.filter { it is State.Finish }.collect {
|
||||
val params = syncParams
|
||||
if (params != null) {
|
||||
syncParams = null
|
||||
connection.unbind(this@Job)
|
||||
jobFinished(params, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
binder.sync(SyncRequest.AUTO)
|
||||
}, onUnbind = { _, binder ->
|
||||
binder.cancelAuto()
|
||||
jobScope.cancel()
|
||||
val params = syncParams
|
||||
if (params != null) {
|
||||
syncParams = null
|
||||
jobFinished(params, true)
|
||||
}
|
||||
})
|
||||
|
||||
override fun onStartJob(params: JobParameters): Boolean {
|
||||
syncParams = params
|
||||
syncConnection.bind(this)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onStopJob(params: JobParameters): Boolean {
|
||||
syncParams = null
|
||||
jobScope.cancel()
|
||||
val reschedule = syncConnection.binder?.cancelAuto() == true
|
||||
syncConnection.unbind(this)
|
||||
return reschedule
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
context: Context,
|
||||
periodMillis: Long,
|
||||
networkType: Int,
|
||||
isCharging: Boolean,
|
||||
isBatteryLow: Boolean
|
||||
): JobInfo = JobInfo.Builder(
|
||||
Constants.JOB_ID_SYNC,
|
||||
ComponentName(context, Job::class.java)
|
||||
).apply {
|
||||
setRequiredNetworkType(networkType)
|
||||
sdkAbove(sdk = Build.VERSION_CODES.O) {
|
||||
setRequiresCharging(isCharging)
|
||||
setRequiresBatteryNotLow(isBatteryLow)
|
||||
setRequiresStorageNotLow(true)
|
||||
}
|
||||
if (SdkCheck.isNougat) {
|
||||
setPeriodic(periodMillis, JobInfo.getMinFlexMillis())
|
||||
} else {
|
||||
setPeriodic(periodMillis)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.looker.droidify.sync
|
||||
|
||||
import android.app.job.JobInfo
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.NetworkType
|
||||
|
||||
data class SyncPreference(
|
||||
val networkType: NetworkType,
|
||||
val pluggedIn: Boolean = false,
|
||||
val batteryNotLow: Boolean = true,
|
||||
)
|
||||
|
||||
fun SyncPreference.toJobNetworkType() = when (networkType) {
|
||||
NetworkType.NOT_REQUIRED -> JobInfo.NETWORK_TYPE_NONE
|
||||
NetworkType.UNMETERED -> JobInfo.NETWORK_TYPE_UNMETERED
|
||||
else -> JobInfo.NETWORK_TYPE_ANY
|
||||
}
|
||||
|
||||
fun SyncPreference.toWorkConstraints(): Constraints = Constraints(
|
||||
requiredNetworkType = networkType,
|
||||
requiresCharging = pluggedIn,
|
||||
requiresBatteryNotLow = batteryNotLow
|
||||
)
|
||||
274
app/src/main/kotlin/com/looker/droidify/ui/MessageDialog.kt
Normal file
274
app/src/main/kotlin/com/looker/droidify/ui/MessageDialog.kt
Normal file
@@ -0,0 +1,274 @@
|
||||
package com.looker.droidify.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.nullIfEmpty
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.ui.repository.RepositoryFragment
|
||||
import com.looker.droidify.utility.PackageItemResolver
|
||||
import com.looker.droidify.utility.extension.android.Android
|
||||
import kotlinx.parcelize.Parceler
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.TypeParceler
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
|
||||
class MessageDialog() : DialogFragment() {
|
||||
companion object {
|
||||
private const val EXTRA_MESSAGE = "message"
|
||||
}
|
||||
|
||||
constructor(message: Message) : this() {
|
||||
arguments = bundleOf(EXTRA_MESSAGE to message)
|
||||
}
|
||||
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
show(fragmentManager, this::class.java.name)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
|
||||
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||
val message = if (SdkCheck.isTiramisu) {
|
||||
arguments?.getParcelable(EXTRA_MESSAGE, Message::class.java)!!
|
||||
} else {
|
||||
arguments?.getParcelable(EXTRA_MESSAGE)!!
|
||||
}
|
||||
when (message) {
|
||||
is Message.DeleteRepositoryConfirm -> {
|
||||
dialog.setTitle(stringRes.confirmation)
|
||||
dialog.setMessage(stringRes.delete_repository_DESC)
|
||||
dialog.setPositiveButton(stringRes.delete) { _, _ ->
|
||||
(parentFragment as RepositoryFragment).onDeleteConfirm()
|
||||
}
|
||||
dialog.setNegativeButton(stringRes.cancel, null)
|
||||
}
|
||||
|
||||
is Message.CantEditSyncing -> {
|
||||
dialog.setTitle(stringRes.action_failed)
|
||||
dialog.setMessage(stringRes.cant_edit_sync_DESC)
|
||||
dialog.setPositiveButton(stringRes.ok, null)
|
||||
}
|
||||
|
||||
is Message.Link -> {
|
||||
dialog.setTitle(stringRes.confirmation)
|
||||
dialog.setMessage(getString(stringRes.open_DESC_FORMAT, message.uri.toString()))
|
||||
dialog.setPositiveButton(stringRes.ok) { _, _ ->
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, message.uri))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
dialog.setNegativeButton(stringRes.cancel, null)
|
||||
}
|
||||
|
||||
is Message.Permissions -> {
|
||||
val packageManager = requireContext().packageManager
|
||||
val builder = StringBuilder()
|
||||
val localCache = PackageItemResolver.LocalCache()
|
||||
val title = if (message.group != null) {
|
||||
val name = try {
|
||||
val permissionGroupInfo =
|
||||
packageManager.getPermissionGroupInfo(message.group, 0)
|
||||
PackageItemResolver.loadLabel(
|
||||
requireContext(),
|
||||
localCache,
|
||||
permissionGroupInfo
|
||||
)?.nullIfEmpty()?.let { if (it == message.group) null else it }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
name ?: getString(stringRes.unknown)
|
||||
} else {
|
||||
getString(stringRes.other)
|
||||
}
|
||||
for (permission in message.permissions) {
|
||||
kotlin.runCatching {
|
||||
val permissionInfo = packageManager.getPermissionInfo(permission, 0)
|
||||
PackageItemResolver.loadDescription(
|
||||
requireContext(),
|
||||
localCache,
|
||||
permissionInfo
|
||||
)?.nullIfEmpty()?.let { if (it == permission) null else it }
|
||||
?: error("Invalid Permission Description")
|
||||
}.onSuccess {
|
||||
builder.append(it).append("\n\n")
|
||||
}
|
||||
}
|
||||
if (builder.isNotEmpty()) {
|
||||
builder.delete(builder.length - 2, builder.length)
|
||||
} else {
|
||||
builder.append(getString(stringRes.no_description_available_DESC))
|
||||
}
|
||||
dialog.setTitle(title)
|
||||
dialog.setMessage(builder)
|
||||
dialog.setPositiveButton(stringRes.ok, null)
|
||||
}
|
||||
|
||||
is Message.ReleaseIncompatible -> {
|
||||
val builder = StringBuilder()
|
||||
val minSdkVersion =
|
||||
if (Release.Incompatibility.MinSdk in message.incompatibilities) {
|
||||
message.minSdkVersion
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val maxSdkVersion =
|
||||
if (Release.Incompatibility.MaxSdk in message.incompatibilities) {
|
||||
message.maxSdkVersion
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (minSdkVersion != null || maxSdkVersion != null) {
|
||||
val versionMessage = minSdkVersion?.let {
|
||||
getString(
|
||||
stringRes.incompatible_api_min_DESC_FORMAT,
|
||||
it
|
||||
)
|
||||
}
|
||||
?: maxSdkVersion?.let {
|
||||
getString(
|
||||
stringRes.incompatible_api_max_DESC_FORMAT,
|
||||
it
|
||||
)
|
||||
}
|
||||
builder.append(
|
||||
getString(
|
||||
stringRes.incompatible_api_DESC_FORMAT,
|
||||
Android.name,
|
||||
SdkCheck.sdk,
|
||||
versionMessage.orEmpty()
|
||||
)
|
||||
).append("\n\n")
|
||||
}
|
||||
if (Release.Incompatibility.Platform in message.incompatibilities) {
|
||||
builder.append(
|
||||
getString(
|
||||
stringRes.incompatible_platforms_DESC_FORMAT,
|
||||
Android.primaryPlatform ?: getString(stringRes.unknown),
|
||||
message.platforms.joinToString(separator = ", ")
|
||||
)
|
||||
).append("\n\n")
|
||||
}
|
||||
val features =
|
||||
message.incompatibilities.mapNotNull { it as? Release.Incompatibility.Feature }
|
||||
if (features.isNotEmpty()) {
|
||||
builder.append(getString(stringRes.incompatible_features_DESC))
|
||||
for (feature in features) {
|
||||
builder.append("\n\u2022 ").append(feature.feature)
|
||||
}
|
||||
builder.append("\n\n")
|
||||
}
|
||||
if (builder.isNotEmpty()) {
|
||||
builder.delete(builder.length - 2, builder.length)
|
||||
}
|
||||
dialog.setTitle(stringRes.incompatible_version)
|
||||
dialog.setMessage(builder)
|
||||
dialog.setPositiveButton(stringRes.ok, null)
|
||||
}
|
||||
|
||||
is Message.ReleaseOlder -> {
|
||||
dialog.setTitle(stringRes.incompatible_version)
|
||||
dialog.setMessage(stringRes.incompatible_older_DESC)
|
||||
dialog.setPositiveButton(stringRes.ok, null)
|
||||
}
|
||||
|
||||
is Message.ReleaseSignatureMismatch -> {
|
||||
dialog.setTitle(stringRes.incompatible_version)
|
||||
dialog.setMessage(stringRes.incompatible_signature_DESC)
|
||||
dialog.setPositiveButton(stringRes.ok, null)
|
||||
}
|
||||
|
||||
is Message.InsufficientStorage -> {
|
||||
dialog.setTitle(stringRes.insufficient_storage)
|
||||
dialog.setMessage(stringRes.insufficient_storage_DESC)
|
||||
dialog.setPositiveButton(stringRes.ok, null)
|
||||
}
|
||||
}::class
|
||||
return dialog.create()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed interface Message : Parcelable {
|
||||
@Parcelize
|
||||
data object DeleteRepositoryConfirm : Message
|
||||
|
||||
@Parcelize
|
||||
data object CantEditSyncing : Message
|
||||
|
||||
@Parcelize
|
||||
class Link(val uri: Uri) : Message
|
||||
|
||||
@Parcelize
|
||||
class Permissions(val group: String?, val permissions: List<String>) : Message
|
||||
|
||||
@Parcelize
|
||||
@TypeParceler<Release.Incompatibility, ReleaseIncompatibilityParceler>
|
||||
class ReleaseIncompatible(
|
||||
val incompatibilities: List<Release.Incompatibility>,
|
||||
val platforms: List<String>,
|
||||
val minSdkVersion: Int,
|
||||
val maxSdkVersion: Int
|
||||
) : Message
|
||||
|
||||
@Parcelize
|
||||
data object ReleaseOlder : Message
|
||||
|
||||
@Parcelize
|
||||
data object ReleaseSignatureMismatch : Message
|
||||
|
||||
@Parcelize
|
||||
data object InsufficientStorage : Message
|
||||
}
|
||||
|
||||
class ReleaseIncompatibilityParceler : Parceler<Release.Incompatibility> {
|
||||
|
||||
private companion object {
|
||||
// Incompatibility indices in `Parcel`
|
||||
const val MIN_SDK_INDEX = 0
|
||||
const val MAX_SDK_INDEX = 1
|
||||
const val PLATFORM_INDEX = 2
|
||||
const val FEATURE_INDEX = 3
|
||||
}
|
||||
|
||||
override fun create(parcel: Parcel): Release.Incompatibility {
|
||||
return when (parcel.readInt()) {
|
||||
MIN_SDK_INDEX -> Release.Incompatibility.MinSdk
|
||||
MAX_SDK_INDEX -> Release.Incompatibility.MaxSdk
|
||||
PLATFORM_INDEX -> Release.Incompatibility.Platform
|
||||
FEATURE_INDEX -> Release.Incompatibility.Feature(requireNotNull(parcel.readString()))
|
||||
else -> error("Invalid Index for Incompatibility")
|
||||
}
|
||||
}
|
||||
|
||||
override fun Release.Incompatibility.write(parcel: Parcel, flags: Int) {
|
||||
when (this) {
|
||||
is Release.Incompatibility.MinSdk -> {
|
||||
parcel.writeInt(MIN_SDK_INDEX)
|
||||
}
|
||||
|
||||
is Release.Incompatibility.MaxSdk -> {
|
||||
parcel.writeInt(MAX_SDK_INDEX)
|
||||
}
|
||||
|
||||
is Release.Incompatibility.Platform -> {
|
||||
parcel.writeInt(PLATFORM_INDEX)
|
||||
}
|
||||
|
||||
is Release.Incompatibility.Feature -> {
|
||||
parcel.writeInt(FEATURE_INDEX)
|
||||
parcel.writeString(feature)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/src/main/kotlin/com/looker/droidify/ui/ScreenFragment.kt
Normal file
31
app/src/main/kotlin/com/looker/droidify/ui/ScreenFragment.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.looker.droidify.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.looker.droidify.databinding.FragmentBinding
|
||||
|
||||
open class ScreenFragment : Fragment() {
|
||||
private var _fragmentBinding: FragmentBinding? = null
|
||||
val fragmentBinding get() = _fragmentBinding!!
|
||||
val toolbar: MaterialToolbar get() = fragmentBinding.toolbar
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
_fragmentBinding = FragmentBinding.inflate(layoutInflater)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View = fragmentBinding.root
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_fragmentBinding = null
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
||||
package com.looker.droidify.ui.appDetail
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import coil.load
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.common.extension.getLauncherActivities
|
||||
import com.looker.core.common.extension.getMutatedIcon
|
||||
import com.looker.core.common.extension.isFirstItemVisible
|
||||
import com.looker.core.common.extension.isSystemApplication
|
||||
import com.looker.core.common.extension.systemBarsPadding
|
||||
import com.looker.core.common.extension.updateAsMutable
|
||||
import com.looker.droidify.content.ProductPreferences
|
||||
import com.looker.droidify.model.InstalledItem
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.ProductPreference
|
||||
import com.looker.droidify.model.Release
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.model.findSuggested
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.DownloadService
|
||||
import com.looker.droidify.ui.Message
|
||||
import com.looker.droidify.ui.MessageDialog
|
||||
import com.looker.droidify.ui.ScreenFragment
|
||||
import com.looker.droidify.ui.appDetail.AppDetailViewModel.Companion.ARG_PACKAGE_NAME
|
||||
import com.looker.droidify.ui.appDetail.AppDetailViewModel.Companion.ARG_REPO_ADDRESS
|
||||
import com.looker.droidify.utility.extension.ImageUtils.url
|
||||
import com.looker.droidify.utility.extension.screenActivity
|
||||
import com.looker.droidify.utility.extension.startUpdate
|
||||
import com.looker.installer.model.InstallState
|
||||
import com.looker.installer.model.isCancellable
|
||||
import com.stfalcon.imageviewer.StfalconImageViewer
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
|
||||
companion object {
|
||||
private const val STATE_LAYOUT_MANAGER = "layoutManager"
|
||||
private const val STATE_ADAPTER = "adapter"
|
||||
}
|
||||
|
||||
constructor(packageName: String, repoAddress: String? = null) : this() {
|
||||
arguments = bundleOf(
|
||||
ARG_PACKAGE_NAME to packageName,
|
||||
ARG_REPO_ADDRESS to repoAddress
|
||||
)
|
||||
}
|
||||
|
||||
private enum class Action(
|
||||
val id: Int,
|
||||
val adapterAction: AppDetailAdapter.Action
|
||||
) {
|
||||
INSTALL(1, AppDetailAdapter.Action.INSTALL),
|
||||
UPDATE(2, AppDetailAdapter.Action.UPDATE),
|
||||
LAUNCH(3, AppDetailAdapter.Action.LAUNCH),
|
||||
DETAILS(4, AppDetailAdapter.Action.DETAILS),
|
||||
UNINSTALL(5, AppDetailAdapter.Action.UNINSTALL),
|
||||
SHARE(6, AppDetailAdapter.Action.SHARE)
|
||||
}
|
||||
|
||||
private class Installed(
|
||||
val installedItem: InstalledItem,
|
||||
val isSystem: Boolean,
|
||||
val launcherActivities: List<Pair<String, String>>
|
||||
)
|
||||
|
||||
private val viewModel: AppDetailViewModel by viewModels()
|
||||
|
||||
private var layoutManagerState: LinearLayoutManager.SavedState? = null
|
||||
|
||||
private var actions = Pair(emptySet<Action>(), null as Action?)
|
||||
private var products = emptyList<Pair<Product, Repository>>()
|
||||
private var installed: Installed? = null
|
||||
private var downloading = false
|
||||
private var installing: InstallState? = null
|
||||
|
||||
private var recyclerView: RecyclerView? = null
|
||||
private var detailAdapter: AppDetailAdapter? = null
|
||||
|
||||
private val downloadConnection = Connection(
|
||||
serviceClass = DownloadService::class.java,
|
||||
onBind = { _, binder ->
|
||||
lifecycleScope.launch {
|
||||
binder.downloadState.collect(::updateDownloadState)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
detailAdapter = AppDetailAdapter(this@AppDetailFragment)
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
toolbar.menu.apply {
|
||||
Action.entries.forEach { action ->
|
||||
add(0, action.id, 0, action.adapterAction.titleResId)
|
||||
.setIcon(toolbar.context.getMutatedIcon(action.adapterAction.iconResId))
|
||||
.setVisible(false)
|
||||
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
.setOnMenuItemClickListener {
|
||||
onActionClick(action.adapterAction)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val content = fragmentBinding.fragmentContent
|
||||
content.addView(
|
||||
RecyclerView(content.context).apply {
|
||||
id = android.R.id.list
|
||||
this.layoutManager = LinearLayoutManager(
|
||||
context,
|
||||
LinearLayoutManager.VERTICAL,
|
||||
false
|
||||
)
|
||||
isMotionEventSplittingEnabled = false
|
||||
isVerticalScrollBarEnabled = false
|
||||
adapter = detailAdapter
|
||||
(itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
if (detailAdapter != null) {
|
||||
savedInstanceState?.getParcelable<AppDetailAdapter.SavedState>(STATE_ADAPTER)
|
||||
?.let(detailAdapter!!::restoreState)
|
||||
}
|
||||
layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER)
|
||||
recyclerView = this
|
||||
systemBarsPadding(includeFab = false)
|
||||
}
|
||||
)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
launch {
|
||||
viewModel.state.collectLatest { state ->
|
||||
products = state.products.mapNotNull { product ->
|
||||
val requiredRepo = state.repos.find { it.id == product.repositoryId }
|
||||
requiredRepo?.let { product to it }
|
||||
}
|
||||
layoutManagerState?.let {
|
||||
recyclerView?.layoutManager!!.onRestoreInstanceState(it)
|
||||
}
|
||||
layoutManagerState = null
|
||||
installed = state.installedItem?.let {
|
||||
with(requireContext().packageManager) {
|
||||
val isSystem = isSystemApplication(viewModel.packageName)
|
||||
val launcherActivities = if (state.isSelf) {
|
||||
emptyList()
|
||||
} else {
|
||||
getLauncherActivities(viewModel.packageName)
|
||||
}
|
||||
Installed(it, isSystem, launcherActivities)
|
||||
}
|
||||
}
|
||||
val adapter = recyclerView?.adapter as? AppDetailAdapter
|
||||
|
||||
// `delay` is cancellable hence it waits for 50 milliseconds to show empty page
|
||||
if (products.isEmpty()) delay(50)
|
||||
|
||||
adapter?.setProducts(
|
||||
context = requireContext(),
|
||||
packageName = viewModel.packageName,
|
||||
suggestedRepo = state.addressIfUnavailable,
|
||||
products = products,
|
||||
installedItem = state.installedItem,
|
||||
isFavourite = state.isFavourite,
|
||||
allowIncompatibleVersion = state.allowIncompatibleVersions
|
||||
)
|
||||
updateButtons()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
viewModel.installerState.collect(::updateInstallState)
|
||||
}
|
||||
launch {
|
||||
recyclerView?.isFirstItemVisible?.collect(::updateToolbarButtons)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
downloadConnection.bind(requireContext())
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
recyclerView = null
|
||||
detailAdapter = null
|
||||
|
||||
downloadConnection.unbind(requireContext())
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
val layoutManagerState =
|
||||
layoutManagerState ?: recyclerView?.layoutManager?.onSaveInstanceState()
|
||||
layoutManagerState?.let { outState.putParcelable(STATE_LAYOUT_MANAGER, it) }
|
||||
val adapterState = (recyclerView?.adapter as? AppDetailAdapter)?.saveState()
|
||||
adapterState?.let { outState.putParcelable(STATE_ADAPTER, it) }
|
||||
}
|
||||
|
||||
private fun updateButtons(
|
||||
preference: ProductPreference = ProductPreferences[viewModel.packageName]
|
||||
) {
|
||||
val installed = installed
|
||||
val product = products.findSuggested(installed?.installedItem)?.first
|
||||
val compatible = product != null && product.selectedReleases.firstOrNull()
|
||||
.let { it != null && it.incompatibilities.isEmpty() }
|
||||
val canInstall = product != null && installed == null && compatible
|
||||
val canUpdate =
|
||||
product != null && compatible && product.canUpdate(installed?.installedItem) &&
|
||||
!preference.shouldIgnoreUpdate(product.versionCode)
|
||||
val canUninstall = product != null && installed != null && !installed.isSystem
|
||||
val canLaunch =
|
||||
product != null && installed != null && installed.launcherActivities.isNotEmpty()
|
||||
|
||||
val actions = buildSet {
|
||||
if (canInstall) add(Action.INSTALL)
|
||||
if (canUpdate) add(Action.UPDATE)
|
||||
if (canLaunch) add(Action.LAUNCH)
|
||||
if (installed != null) add(Action.DETAILS)
|
||||
if (canUninstall) add(Action.UNINSTALL)
|
||||
add(Action.SHARE)
|
||||
}
|
||||
|
||||
val primaryAction = when {
|
||||
canUpdate -> Action.UPDATE
|
||||
canLaunch -> Action.LAUNCH
|
||||
canInstall -> Action.INSTALL
|
||||
installed != null -> Action.DETAILS
|
||||
else -> Action.SHARE
|
||||
}
|
||||
|
||||
val adapterAction = when {
|
||||
installing == InstallState.Installing -> null
|
||||
installing == InstallState.Pending -> AppDetailAdapter.Action.CANCEL
|
||||
downloading -> AppDetailAdapter.Action.CANCEL
|
||||
else -> primaryAction.adapterAction
|
||||
}
|
||||
|
||||
(recyclerView?.adapter as? AppDetailAdapter)?.action = adapterAction
|
||||
|
||||
for (action in sequenceOf(
|
||||
Action.INSTALL,
|
||||
Action.UPDATE,
|
||||
)) {
|
||||
toolbar.menu.findItem(action.id).isEnabled = !downloading
|
||||
}
|
||||
this.actions = Pair(actions, primaryAction)
|
||||
updateToolbarButtons()
|
||||
}
|
||||
|
||||
private fun updateToolbarButtons(
|
||||
isActionVisible: Boolean = (recyclerView?.layoutManager as LinearLayoutManager)
|
||||
.findFirstVisibleItemPosition() == 0
|
||||
) {
|
||||
toolbar.title = if (isActionVisible) {
|
||||
getString(stringRes.application)
|
||||
} else {
|
||||
products.firstOrNull()?.first?.name ?: getString(stringRes.application)
|
||||
}
|
||||
val (actions, primaryAction) = actions
|
||||
val displayActions = actions.updateAsMutable {
|
||||
if (isActionVisible && primaryAction != null) {
|
||||
remove(primaryAction)
|
||||
}
|
||||
if (size >= 4 && resources.configuration.screenWidthDp < 400) {
|
||||
remove(Action.DETAILS)
|
||||
}
|
||||
}
|
||||
Action.entries.forEach { action ->
|
||||
toolbar.menu.findItem(action.id).isVisible = action in displayActions
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateInstallState(installerState: InstallState?) {
|
||||
val status = when (installerState) {
|
||||
InstallState.Pending -> AppDetailAdapter.Status.PendingInstall
|
||||
InstallState.Installing -> AppDetailAdapter.Status.Installing
|
||||
else -> AppDetailAdapter.Status.Idle
|
||||
}
|
||||
(recyclerView?.adapter as? AppDetailAdapter)?.status = status
|
||||
installing = installerState
|
||||
updateButtons()
|
||||
}
|
||||
|
||||
private fun updateDownloadState(state: DownloadService.DownloadState) {
|
||||
val packageName = viewModel.packageName
|
||||
val isPending = packageName in state.queue
|
||||
val isDownloading = state isDownloading packageName
|
||||
val isCompleted = state isComplete packageName
|
||||
val isActive = isPending || isDownloading
|
||||
if (isPending) {
|
||||
detailAdapter?.status = AppDetailAdapter.Status.Pending
|
||||
}
|
||||
if (isDownloading) {
|
||||
detailAdapter?.status = when (state.currentItem) {
|
||||
is DownloadService.State.Connecting -> AppDetailAdapter.Status.Connecting
|
||||
is DownloadService.State.Downloading -> AppDetailAdapter.Status.Downloading(
|
||||
state.currentItem.read,
|
||||
state.currentItem.total
|
||||
)
|
||||
|
||||
else -> AppDetailAdapter.Status.Idle
|
||||
}
|
||||
}
|
||||
if (isCompleted) {
|
||||
detailAdapter?.status = AppDetailAdapter.Status.Idle
|
||||
}
|
||||
if (this.downloading != isActive) {
|
||||
this.downloading = isActive
|
||||
updateButtons()
|
||||
}
|
||||
if (state.currentItem is DownloadService.State.Success && isResumed) {
|
||||
viewModel.installPackage(
|
||||
state.currentItem.packageName,
|
||||
state.currentItem.release.cacheFileName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActionClick(action: AppDetailAdapter.Action) {
|
||||
when (action) {
|
||||
AppDetailAdapter.Action.INSTALL,
|
||||
AppDetailAdapter.Action.UPDATE -> {
|
||||
if (Cache.getEmptySpace(requireContext()) < products.first().first.releases.first().size) {
|
||||
MessageDialog(Message.InsufficientStorage).show(childFragmentManager)
|
||||
return
|
||||
}
|
||||
downloadConnection.startUpdate(
|
||||
viewModel.packageName,
|
||||
installed?.installedItem,
|
||||
products
|
||||
)
|
||||
}
|
||||
|
||||
AppDetailAdapter.Action.LAUNCH -> {
|
||||
val launcherActivities = installed?.launcherActivities.orEmpty()
|
||||
if (launcherActivities.size >= 2) {
|
||||
LaunchDialog(launcherActivities).show(
|
||||
childFragmentManager,
|
||||
LaunchDialog::class.java.name
|
||||
)
|
||||
} else {
|
||||
launcherActivities.firstOrNull()?.let { startLauncherActivity(it.first) }
|
||||
}
|
||||
}
|
||||
|
||||
AppDetailAdapter.Action.DETAILS -> {
|
||||
startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
"package:${viewModel.packageName}".toUri()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
AppDetailAdapter.Action.UNINSTALL -> viewModel.uninstallPackage()
|
||||
|
||||
AppDetailAdapter.Action.CANCEL -> {
|
||||
val binder = downloadConnection.binder
|
||||
if (installing?.isCancellable == true) {
|
||||
viewModel.removeQueue()
|
||||
} else if (downloading && binder != null) {
|
||||
binder.cancel(viewModel.packageName)
|
||||
}
|
||||
}
|
||||
|
||||
AppDetailAdapter.Action.SHARE -> {
|
||||
val repo = products[0].second
|
||||
val address = when {
|
||||
repo.name == "F-Droid" ->
|
||||
"https://f-droid.org/packages/" +
|
||||
"${viewModel.packageName}/"
|
||||
|
||||
"IzzyOnDroid" in repo.name -> {
|
||||
"https://apt.izzysoft.de/fdroid/index/apk/${viewModel.packageName}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"https://droidify.eu.org/app/?id=" +
|
||||
"${viewModel.packageName}&repo_address=${repo.address}"
|
||||
}
|
||||
}
|
||||
val sendIntent = Intent(Intent.ACTION_SEND)
|
||||
.putExtra(Intent.EXTRA_TEXT, address)
|
||||
.setType("text/plain")
|
||||
startActivity(Intent.createChooser(sendIntent, null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFavouriteClicked() {
|
||||
viewModel.setFavouriteState()
|
||||
}
|
||||
|
||||
private fun startLauncherActivity(name: String) {
|
||||
try {
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setComponent(ComponentName(viewModel.packageName, name))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceChanged(preference: ProductPreference) {
|
||||
updateButtons(preference)
|
||||
}
|
||||
|
||||
override fun onPermissionsClick(group: String?, permissions: List<String>) {
|
||||
MessageDialog(Message.Permissions(group, permissions))
|
||||
.show(childFragmentManager)
|
||||
}
|
||||
|
||||
override fun onScreenshotClick(screenshot: Product.Screenshot, parentView: ImageView) {
|
||||
val product = products
|
||||
.firstOrNull { (product, _) ->
|
||||
product.screenshots.find { it === screenshot }?.identifier != null
|
||||
}
|
||||
?: return
|
||||
val screenshots = product.first.screenshots
|
||||
val position = screenshots.indexOfFirst { screenshot.identifier == it.identifier }
|
||||
StfalconImageViewer
|
||||
.Builder(context, screenshots) { view, current ->
|
||||
view.load(current.url(product.second, viewModel.packageName))
|
||||
}
|
||||
.withStartPosition(position)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onReleaseClick(release: Release) {
|
||||
val installedItem = installed?.installedItem
|
||||
when {
|
||||
release.incompatibilities.isNotEmpty() -> {
|
||||
MessageDialog(
|
||||
Message.ReleaseIncompatible(
|
||||
release.incompatibilities,
|
||||
release.platforms,
|
||||
release.minSdkVersion,
|
||||
release.maxSdkVersion
|
||||
)
|
||||
).show(childFragmentManager)
|
||||
}
|
||||
|
||||
Cache.getEmptySpace(requireContext()) < release.size -> {
|
||||
MessageDialog(Message.InsufficientStorage).show(childFragmentManager)
|
||||
}
|
||||
|
||||
installedItem != null && installedItem.versionCode > release.versionCode -> {
|
||||
MessageDialog(Message.ReleaseOlder).show(childFragmentManager)
|
||||
}
|
||||
|
||||
installedItem != null && installedItem.signature != release.signature -> {
|
||||
lifecycleScope.launch {
|
||||
if (viewModel.shouldIgnoreSignature()) {
|
||||
queueReleaseInstall(release, installedItem)
|
||||
} else {
|
||||
MessageDialog(Message.ReleaseSignatureMismatch).show(childFragmentManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
queueReleaseInstall(release, installedItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueReleaseInstall(release: Release, installedItem: InstalledItem?) {
|
||||
val productRepository =
|
||||
products.asSequence().filter { (product, _) ->
|
||||
product.releases.any { it === release }
|
||||
}.firstOrNull()
|
||||
if (productRepository != null) {
|
||||
downloadConnection.binder?.enqueue(
|
||||
viewModel.packageName,
|
||||
productRepository.first.name,
|
||||
productRepository.second,
|
||||
release,
|
||||
installedItem != null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestAddRepository(address: String) {
|
||||
screenActivity.navigateAddRepository(address)
|
||||
}
|
||||
|
||||
override fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean {
|
||||
return if (shouldConfirm && (uri.scheme == "http" || uri.scheme == "https")) {
|
||||
MessageDialog(Message.Link(uri)).show(childFragmentManager)
|
||||
true
|
||||
} else {
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, uri))
|
||||
true
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LaunchDialog() : DialogFragment() {
|
||||
companion object {
|
||||
private const val EXTRA_NAMES = "names"
|
||||
private const val EXTRA_LABELS = "labels"
|
||||
}
|
||||
|
||||
constructor(launcherActivities: List<Pair<String, String>>) : this() {
|
||||
arguments = Bundle().apply {
|
||||
putStringArrayList(EXTRA_NAMES, ArrayList(launcherActivities.map { it.first }))
|
||||
putStringArrayList(EXTRA_LABELS, ArrayList(launcherActivities.map { it.second }))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
|
||||
val names = requireArguments().getStringArrayList(EXTRA_NAMES)!!
|
||||
val labels = requireArguments().getStringArrayList(EXTRA_LABELS)!!
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(stringRes.launch)
|
||||
.setItems(labels.toTypedArray()) { _, position ->
|
||||
(parentFragment as AppDetailFragment)
|
||||
.startLauncherActivity(names[position])
|
||||
}
|
||||
.setNegativeButton(stringRes.cancel, null)
|
||||
.create()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.looker.droidify.ui.appDetail
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.looker.core.common.extension.asStateFlow
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.domain.model.toPackageName
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.model.InstalledItem
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.installer.InstallManager
|
||||
import com.looker.installer.model.InstallState
|
||||
import com.looker.installer.model.installFrom
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AppDetailViewModel @Inject constructor(
|
||||
private val installer: InstallManager,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
val packageName: String = requireNotNull(savedStateHandle[ARG_PACKAGE_NAME])
|
||||
|
||||
private val repoAddress: StateFlow<String?> =
|
||||
savedStateHandle.getStateFlow(ARG_REPO_ADDRESS, null)
|
||||
|
||||
val installerState: StateFlow<InstallState?> =
|
||||
installer.state.mapNotNull { stateMap ->
|
||||
stateMap[packageName.toPackageName()]
|
||||
}.asStateFlow(null)
|
||||
|
||||
val state =
|
||||
combine(
|
||||
Database.ProductAdapter.getStream(packageName),
|
||||
Database.RepositoryAdapter.getAllStream(),
|
||||
Database.InstalledAdapter.getStream(packageName),
|
||||
repoAddress,
|
||||
flow { emit(settingsRepository.getInitial()) }
|
||||
) { products, repositories, installedItem, suggestedAddress, initialSettings ->
|
||||
val idAndRepos = repositories.associateBy { it.id }
|
||||
val filteredProducts = products.filter { product ->
|
||||
idAndRepos[product.repositoryId] != null
|
||||
}
|
||||
AppDetailUiState(
|
||||
products = filteredProducts,
|
||||
repos = repositories,
|
||||
installedItem = installedItem,
|
||||
isFavourite = packageName in initialSettings.favouriteApps,
|
||||
allowIncompatibleVersions = initialSettings.incompatibleVersions,
|
||||
isSelf = packageName == BuildConfig.APPLICATION_ID,
|
||||
addressIfUnavailable = suggestedAddress
|
||||
)
|
||||
}.asStateFlow(AppDetailUiState())
|
||||
|
||||
suspend fun shouldIgnoreSignature(): Boolean {
|
||||
return settingsRepository.getInitial().ignoreSignature
|
||||
}
|
||||
|
||||
fun setFavouriteState() {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.toggleFavourites(packageName)
|
||||
}
|
||||
}
|
||||
|
||||
fun installPackage(packageName: String, fileName: String) {
|
||||
viewModelScope.launch {
|
||||
installer install (packageName installFrom fileName)
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstallPackage() {
|
||||
viewModelScope.launch {
|
||||
installer uninstall packageName.toPackageName()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeQueue() {
|
||||
viewModelScope.launch {
|
||||
installer remove packageName.toPackageName()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ARG_PACKAGE_NAME = "package_name"
|
||||
const val ARG_REPO_ADDRESS = "repo_address"
|
||||
}
|
||||
}
|
||||
|
||||
data class AppDetailUiState(
|
||||
val products: List<Product> = emptyList(),
|
||||
val repos: List<Repository> = emptyList(),
|
||||
val installedItem: InstalledItem? = null,
|
||||
val isSelf: Boolean = false,
|
||||
val isFavourite: Boolean = false,
|
||||
val allowIncompatibleVersions: Boolean = false,
|
||||
val addressIfUnavailable: String? = null
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.looker.droidify.ui.appDetail
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.dispose
|
||||
import coil.load
|
||||
import coil.size.Dimension
|
||||
import coil.size.Scale
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import com.looker.core.common.extension.aspectRatio
|
||||
import com.looker.core.common.extension.authentication
|
||||
import com.looker.core.common.extension.camera
|
||||
import com.looker.core.common.extension.dp
|
||||
import com.looker.core.common.extension.dpToPx
|
||||
import com.looker.core.common.extension.getColorFromAttr
|
||||
import com.looker.core.common.extension.selectableBackground
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.graphics.PaddingDrawable
|
||||
import com.looker.droidify.utility.extension.ImageUtils.url
|
||||
import com.looker.droidify.widget.StableRecyclerAdapter
|
||||
import com.google.android.material.R as MaterialR
|
||||
import com.looker.core.common.R.dimen as dimenRes
|
||||
|
||||
class ScreenshotsAdapter(private val onClick: (Product.Screenshot, ImageView) -> Unit) :
|
||||
StableRecyclerAdapter<ScreenshotsAdapter.ViewType, RecyclerView.ViewHolder>() {
|
||||
enum class ViewType { SCREENSHOT }
|
||||
|
||||
private val items = mutableListOf<Item.ScreenshotItem>()
|
||||
|
||||
private class ViewHolder(context: Context) :
|
||||
RecyclerView.ViewHolder(FrameLayout(context)) {
|
||||
val image: ShapeableImageView = object : ShapeableImageView(context) {
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
setMeasuredDimension(measuredWidth, measuredHeight)
|
||||
}
|
||||
}
|
||||
val placeholderColor = context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer)
|
||||
val radius = context.resources.getDimension(dimenRes.shape_small_corner)
|
||||
|
||||
val imageShapeModel = image.shapeAppearanceModel.toBuilder()
|
||||
.setAllCornerSizes(radius)
|
||||
.build()
|
||||
val cameraIcon = context.camera
|
||||
.apply { setTintList(placeholderColor) }
|
||||
val placeholder: Drawable = PaddingDrawable(cameraIcon, 3f, context.aspectRatio)
|
||||
|
||||
init {
|
||||
with(image) {
|
||||
layout(0, 0, 0, 0)
|
||||
adjustViewBounds = true
|
||||
shapeAppearanceModel = imageShapeModel
|
||||
background = context.selectableBackground
|
||||
isFocusable = true
|
||||
}
|
||||
with(itemView as FrameLayout) {
|
||||
addView(image)
|
||||
layoutParams = RecyclerView.LayoutParams(
|
||||
RecyclerView.LayoutParams.WRAP_CONTENT,
|
||||
150.dp,
|
||||
).apply {
|
||||
marginStart = radius.toInt()
|
||||
marginEnd = radius.toInt()
|
||||
}
|
||||
foregroundGravity = Gravity.CENTER
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setScreenshots(
|
||||
repository: Repository,
|
||||
packageName: String,
|
||||
screenshots: List<Product.Screenshot>
|
||||
) {
|
||||
items.clear()
|
||||
items += screenshots.map { Item.ScreenshotItem(repository, packageName, it) }
|
||||
notifyItemRangeInserted(0, screenshots.size)
|
||||
}
|
||||
|
||||
override val viewTypeClass: Class<ViewType>
|
||||
get() = ViewType::class.java
|
||||
|
||||
override fun getItemEnumViewType(position: Int): ViewType {
|
||||
return ViewType.SCREENSHOT
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: ViewType
|
||||
): RecyclerView.ViewHolder {
|
||||
return ViewHolder(parent.context).apply {
|
||||
image.setOnClickListener {
|
||||
onClick(
|
||||
items[absoluteAdapterPosition].screenshot,
|
||||
it as ImageView
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemDescriptor(position: Int): String = items[position].descriptor
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
holder as ViewHolder
|
||||
val item = items[position]
|
||||
with(holder.image) {
|
||||
load(item.screenshot.url(item.repository, item.packageName)) {
|
||||
size(Dimension.Undefined, Dimension(150.dp.dpToPx.toInt()))
|
||||
scale(Scale.FIT)
|
||||
placeholder(holder.placeholder)
|
||||
error(holder.placeholder)
|
||||
authentication(item.repository.authentication)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
||||
super.onViewRecycled(holder)
|
||||
holder as ViewHolder
|
||||
holder.image.dispose()
|
||||
}
|
||||
|
||||
private sealed class Item {
|
||||
abstract val descriptor: String
|
||||
|
||||
class ScreenshotItem(
|
||||
val repository: Repository,
|
||||
val packageName: String,
|
||||
val screenshot: Product.Screenshot
|
||||
) : Item() {
|
||||
override val descriptor: String
|
||||
get() = "screenshot.${repository.id}.${screenshot.identifier}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.looker.droidify.ui.appList
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.load
|
||||
import com.google.android.material.R as MaterialR
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
import com.looker.core.common.extension.authentication
|
||||
import com.looker.core.common.extension.corneredBackground
|
||||
import com.looker.core.common.extension.dp
|
||||
import com.looker.core.common.extension.getColorFromAttr
|
||||
import com.looker.core.common.extension.inflate
|
||||
import com.looker.core.common.extension.setTextSizeScaled
|
||||
import com.looker.core.common.nullIfEmpty
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.utility.extension.ImageUtils.icon
|
||||
import com.looker.droidify.utility.extension.resources.TypefaceExtra
|
||||
import com.looker.droidify.widget.CursorRecyclerAdapter
|
||||
|
||||
class AppListAdapter(
|
||||
private val source: AppListFragment.Source,
|
||||
private val onClick: (ProductItem) -> Unit
|
||||
) : CursorRecyclerAdapter<AppListAdapter.ViewType, RecyclerView.ViewHolder>() {
|
||||
|
||||
enum class ViewType { PRODUCT, LOADING, EMPTY }
|
||||
|
||||
private class ProductViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val name = itemView.findViewById<TextView>(R.id.name)!!
|
||||
val status = itemView.findViewById<TextView>(R.id.status)!!
|
||||
val summary = itemView.findViewById<TextView>(R.id.summary)!!
|
||||
val icon = itemView.findViewById<ShapeableImageView>(R.id.icon)!!
|
||||
}
|
||||
|
||||
private class LoadingViewHolder(context: Context) :
|
||||
RecyclerView.ViewHolder(FrameLayout(context)) {
|
||||
init {
|
||||
with(itemView as FrameLayout) {
|
||||
val progressBar = CircularProgressIndicator(context)
|
||||
addView(progressBar)
|
||||
layoutParams = RecyclerView.LayoutParams(
|
||||
RecyclerView.LayoutParams.MATCH_PARENT,
|
||||
RecyclerView.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class EmptyViewHolder(context: Context) :
|
||||
RecyclerView.ViewHolder(TextView(context)) {
|
||||
val text: TextView
|
||||
get() = itemView as TextView
|
||||
|
||||
init {
|
||||
with(itemView as TextView) {
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(20.dp, 20.dp, 20.dp, 20.dp)
|
||||
typeface = TypefaceExtra.light
|
||||
setTextColor(context.getColorFromAttr(android.R.attr.colorPrimary))
|
||||
setTextSizeScaled(20)
|
||||
layoutParams = RecyclerView.LayoutParams(
|
||||
RecyclerView.LayoutParams.MATCH_PARENT,
|
||||
RecyclerView.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var repositories: Map<Long, Repository> = emptyMap()
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
var emptyText: String = ""
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
if (isEmpty) {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val viewTypeClass: Class<ViewType>
|
||||
get() = ViewType::class.java
|
||||
|
||||
private val isEmpty: Boolean
|
||||
get() = super.getItemCount() == 0
|
||||
|
||||
override fun getItemCount(): Int = if (isEmpty) 1 else super.getItemCount()
|
||||
override fun getItemId(position: Int): Long = if (isEmpty) -1 else super.getItemId(position)
|
||||
|
||||
override fun getItemEnumViewType(position: Int): ViewType {
|
||||
return when {
|
||||
!isEmpty -> ViewType.PRODUCT
|
||||
cursor == null -> ViewType.LOADING
|
||||
else -> ViewType.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
private fun getProductItem(position: Int): ProductItem {
|
||||
return Database.ProductAdapter.transformItem(moveTo(position.coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: ViewType
|
||||
): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
ViewType.PRODUCT -> ProductViewHolder(parent.inflate(R.layout.product_item)).apply {
|
||||
itemView.setOnClickListener { onClick(getProductItem(absoluteAdapterPosition)) }
|
||||
}
|
||||
|
||||
ViewType.LOADING -> LoadingViewHolder(parent.context)
|
||||
ViewType.EMPTY -> EmptyViewHolder(parent.context)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (getItemEnumViewType(position)) {
|
||||
ViewType.PRODUCT -> {
|
||||
holder as ProductViewHolder
|
||||
val productItem = getProductItem(position)
|
||||
holder.name.text = productItem.name
|
||||
holder.summary.text = productItem.summary
|
||||
holder.summary.isVisible =
|
||||
productItem.summary.isNotEmpty() && productItem.name != productItem.summary
|
||||
val repository: Repository? = repositories[productItem.repositoryId]
|
||||
if (repository != null) {
|
||||
val iconUrl = productItem.icon(view = holder.icon, repository = repository)
|
||||
holder.icon.load(iconUrl) {
|
||||
authentication(repository.authentication)
|
||||
}
|
||||
}
|
||||
with(holder.status) {
|
||||
val versionText = if (source == AppListFragment.Source.UPDATES) {
|
||||
productItem.version
|
||||
} else {
|
||||
productItem.installedVersion.nullIfEmpty() ?: productItem.version
|
||||
}
|
||||
text = versionText
|
||||
val isInstalled = productItem.installedVersion.nullIfEmpty() != null
|
||||
when {
|
||||
productItem.canUpdate -> {
|
||||
backgroundTintList =
|
||||
context.getColorFromAttr(MaterialR.attr.colorTertiaryContainer)
|
||||
setTextColor(
|
||||
context.getColorFromAttr(MaterialR.attr.colorOnTertiaryContainer)
|
||||
)
|
||||
}
|
||||
|
||||
isInstalled -> {
|
||||
backgroundTintList =
|
||||
context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer)
|
||||
setTextColor(
|
||||
context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
setPadding(0, 0, 0, 0)
|
||||
setTextColor(
|
||||
holder.status.context.getColorFromAttr(
|
||||
MaterialR.attr.colorOnBackground
|
||||
)
|
||||
)
|
||||
background = null
|
||||
return@with
|
||||
}
|
||||
}
|
||||
background = context.corneredBackground
|
||||
6.dp.let { setPadding(it, it, it, it) }
|
||||
}
|
||||
val enabled = productItem.compatible || productItem.installedVersion.isNotEmpty()
|
||||
sequenceOf(holder.name, holder.status, holder.summary).forEach {
|
||||
it.isEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
ViewType.LOADING -> {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
ViewType.EMPTY -> {
|
||||
holder as EmptyViewHolder
|
||||
holder.text.text = emptyText
|
||||
}
|
||||
}::class
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.looker.droidify.ui.appList
|
||||
|
||||
import android.database.Cursor
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.looker.core.common.Scroller
|
||||
import com.looker.core.common.R as CommonR
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
import com.looker.core.common.extension.dp
|
||||
import com.looker.core.common.extension.isFirstItemVisible
|
||||
import com.looker.core.common.extension.systemBarsMargin
|
||||
import com.looker.core.common.extension.systemBarsPadding
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.database.CursorOwner
|
||||
import com.looker.droidify.databinding.RecyclerViewWithFabBinding
|
||||
import com.looker.droidify.utility.extension.screenActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AppListFragment() : Fragment(), CursorOwner.Callback {
|
||||
|
||||
private val viewModel: AppListViewModel by viewModels()
|
||||
|
||||
private var _binding: RecyclerViewWithFabBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
companion object {
|
||||
private const val STATE_LAYOUT_MANAGER = "layoutManager"
|
||||
|
||||
private const val EXTRA_SOURCE = "source"
|
||||
}
|
||||
|
||||
enum class Source(
|
||||
val titleResId: Int,
|
||||
val sections: Boolean,
|
||||
val order: Boolean,
|
||||
val updateAll: Boolean
|
||||
) {
|
||||
AVAILABLE(stringRes.available, true, true, false),
|
||||
INSTALLED(stringRes.installed, false, true, false),
|
||||
UPDATES(stringRes.updates, false, false, true)
|
||||
}
|
||||
|
||||
constructor(source: Source) : this() {
|
||||
arguments = Bundle().apply {
|
||||
putString(EXTRA_SOURCE, source.name)
|
||||
}
|
||||
}
|
||||
|
||||
val source: Source
|
||||
get() = requireArguments().getString(EXTRA_SOURCE)!!.let(Source::valueOf)
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
private lateinit var recyclerViewAdapter: AppListAdapter
|
||||
private var shortAnimationDuration: Int = 0
|
||||
private var layoutManagerState: Parcelable? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = RecyclerViewWithFabBinding.inflate(inflater, container, false)
|
||||
|
||||
shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
|
||||
|
||||
viewModel.syncConnection.bind(requireContext())
|
||||
|
||||
recyclerView = binding.recyclerView.apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
isMotionEventSplittingEnabled = false
|
||||
setHasFixedSize(true)
|
||||
recycledViewPool.setMaxRecycledViews(AppListAdapter.ViewType.PRODUCT.ordinal, 30)
|
||||
recyclerViewAdapter = AppListAdapter(source) {
|
||||
screenActivity.navigateProduct(it.packageName)
|
||||
}
|
||||
adapter = recyclerViewAdapter
|
||||
systemBarsPadding()
|
||||
}
|
||||
val fab = binding.scrollUp
|
||||
with(fab) {
|
||||
if (source.updateAll) {
|
||||
text = getString(CommonR.string.update_all)
|
||||
setOnClickListener { viewModel.updateAll() }
|
||||
setIconResource(CommonR.drawable.ic_download)
|
||||
alpha = 1f
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.showUpdateAllButton.collect {
|
||||
isVisible = it
|
||||
}
|
||||
}
|
||||
systemBarsMargin(16.dp)
|
||||
} else {
|
||||
text = ""
|
||||
setIconResource(CommonR.drawable.arrow_up)
|
||||
setOnClickListener {
|
||||
val scroller = Scroller(requireContext())
|
||||
scroller.targetPosition = 0
|
||||
recyclerView.layoutManager?.startSmoothScroll(scroller)
|
||||
}
|
||||
alpha = 0f
|
||||
isVisible = true
|
||||
systemBarsMargin(16.dp)
|
||||
}
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
if (!source.updateAll) {
|
||||
recyclerView.isFirstItemVisible.collect { showFab ->
|
||||
fab.animate()
|
||||
.alpha(if (!showFab) 1f else 0f)
|
||||
.setDuration(shortAnimationDuration.toLong())
|
||||
.setListener(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER)
|
||||
|
||||
updateRequest()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
launch {
|
||||
viewModel.reposStream.collect { repos ->
|
||||
recyclerViewAdapter.repositories = repos.associateBy { it.id }
|
||||
}
|
||||
}
|
||||
launch {
|
||||
viewModel.sortOrderFlow.collect {
|
||||
updateRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
(layoutManagerState ?: recyclerView.layoutManager?.onSaveInstanceState())
|
||||
?.let { outState.putParcelable(STATE_LAYOUT_MANAGER, it) }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
viewModel.syncConnection.unbind(requireContext())
|
||||
_binding = null
|
||||
screenActivity.cursorOwner.detach(this)
|
||||
}
|
||||
|
||||
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {
|
||||
recyclerViewAdapter.cursor = cursor
|
||||
recyclerViewAdapter.emptyText = when {
|
||||
cursor == null -> ""
|
||||
viewModel.searchQuery.value.isNotEmpty() -> {
|
||||
getString(stringRes.no_matching_applications_found)
|
||||
}
|
||||
|
||||
else -> when (source) {
|
||||
Source.AVAILABLE -> getString(stringRes.no_applications_available)
|
||||
Source.INSTALLED -> getString(stringRes.no_applications_installed)
|
||||
Source.UPDATES -> getString(stringRes.all_applications_up_to_date)
|
||||
}
|
||||
}
|
||||
layoutManagerState?.let {
|
||||
layoutManagerState = null
|
||||
recyclerView.layoutManager?.onRestoreInstanceState(it)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun setSearchQuery(searchQuery: String) {
|
||||
viewModel.setSearchQuery(searchQuery) {
|
||||
updateRequest()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun setSection(section: ProductItem.Section) {
|
||||
viewModel.setSection(section) {
|
||||
updateRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRequest() {
|
||||
if (view != null) {
|
||||
screenActivity.cursorOwner.attach(this, viewModel.request(source))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.looker.droidify.ui.appList
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.looker.core.common.extension.asStateFlow
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.core.datastore.model.SortOrder
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.model.ProductItem.Section.All
|
||||
import com.looker.droidify.database.CursorOwner
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class AppListViewModel
|
||||
@Inject constructor(
|
||||
settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val reposStream = Database.RepositoryAdapter
|
||||
.getAllStream()
|
||||
.asStateFlow(emptyList())
|
||||
|
||||
val showUpdateAllButton = Database.ProductAdapter
|
||||
.getUpdatesStream()
|
||||
.map { it.isNotEmpty() }
|
||||
.asStateFlow(false)
|
||||
|
||||
val sortOrderFlow = settingsRepository.get { sortOrder }
|
||||
.asStateFlow(SortOrder.UPDATED)
|
||||
|
||||
private val sections = MutableStateFlow<ProductItem.Section>(All)
|
||||
|
||||
val searchQuery = MutableStateFlow("")
|
||||
|
||||
val syncConnection = Connection(SyncService::class.java)
|
||||
|
||||
fun updateAll() {
|
||||
viewModelScope.launch {
|
||||
syncConnection.binder?.updateAllApps()
|
||||
}
|
||||
}
|
||||
|
||||
fun request(source: AppListFragment.Source): CursorOwner.Request {
|
||||
return when (source) {
|
||||
AppListFragment.Source.AVAILABLE -> CursorOwner.Request.ProductsAvailable(
|
||||
searchQuery.value,
|
||||
sections.value,
|
||||
sortOrderFlow.value
|
||||
)
|
||||
|
||||
AppListFragment.Source.INSTALLED -> CursorOwner.Request.ProductsInstalled(
|
||||
searchQuery.value,
|
||||
sections.value,
|
||||
sortOrderFlow.value
|
||||
)
|
||||
|
||||
AppListFragment.Source.UPDATES -> CursorOwner.Request.ProductsUpdates(
|
||||
searchQuery.value,
|
||||
sections.value,
|
||||
sortOrderFlow.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSection(newSection: ProductItem.Section, perform: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
if (newSection != sections.value) {
|
||||
sections.emit(newSection)
|
||||
launch(Dispatchers.Main) { perform() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearchQuery(newSearchQuery: String, perform: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
if (newSearchQuery != searchQuery.value) {
|
||||
searchQuery.emit(newSearchQuery)
|
||||
launch(Dispatchers.Main) { perform() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.looker.droidify.ui.favourites
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.load
|
||||
import com.google.android.material.R as MaterialR
|
||||
import com.looker.core.common.extension.authentication
|
||||
import com.looker.core.common.extension.corneredBackground
|
||||
import com.looker.core.common.extension.dp
|
||||
import com.looker.core.common.extension.getColorFromAttr
|
||||
import com.looker.core.common.nullIfEmpty
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.databinding.ProductItemBinding
|
||||
import com.looker.droidify.utility.extension.ImageUtils.icon
|
||||
|
||||
class FavouriteFragmentAdapter(
|
||||
private val onProductClick: (String) -> Unit
|
||||
) : RecyclerView.Adapter<FavouriteFragmentAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(binding: ProductItemBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
val icon = binding.icon
|
||||
val name = binding.name
|
||||
val summary = binding.summary
|
||||
val version = binding.status
|
||||
}
|
||||
|
||||
var apps: List<List<Product>> = emptyList()
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
var repositories: Map<Long, Repository> = emptyMap()
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
|
||||
ViewHolder(
|
||||
ProductItemBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
).apply {
|
||||
itemView.setOnClickListener {
|
||||
if (apps.isNotEmpty() && apps[absoluteAdapterPosition].firstOrNull() != null) {
|
||||
onProductClick(apps[absoluteAdapterPosition].first().packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = apps.size
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = apps[position].first().item()
|
||||
val repository: Repository? = repositories[item.repositoryId]
|
||||
holder.name.text = item.name
|
||||
holder.summary.isVisible = item.summary.isNotEmpty()
|
||||
holder.summary.text = item.summary
|
||||
if (repository != null) {
|
||||
val iconUrl = item.icon(holder.icon, repository)
|
||||
holder.icon.load(iconUrl) {
|
||||
authentication(repository.authentication)
|
||||
}
|
||||
}
|
||||
holder.version.apply {
|
||||
text = item.installedVersion.nullIfEmpty() ?: item.version
|
||||
val isInstalled = item.installedVersion.nullIfEmpty() != null
|
||||
when {
|
||||
item.canUpdate -> {
|
||||
backgroundTintList =
|
||||
context.getColorFromAttr(MaterialR.attr.colorSecondaryContainer)
|
||||
setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnSecondaryContainer))
|
||||
}
|
||||
|
||||
isInstalled -> {
|
||||
backgroundTintList =
|
||||
context.getColorFromAttr(MaterialR.attr.colorPrimaryContainer)
|
||||
setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnPrimaryContainer))
|
||||
}
|
||||
|
||||
else -> {
|
||||
setPadding(0, 0, 0, 0)
|
||||
setTextColor(context.getColorFromAttr(MaterialR.attr.colorOnBackground))
|
||||
background = null
|
||||
return@apply
|
||||
}
|
||||
}
|
||||
background = context.corneredBackground
|
||||
6.dp.let { setPadding(it, it, it, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.looker.droidify.ui.favourites
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.looker.core.common.R as CommonR
|
||||
import com.looker.core.common.extension.systemBarsPadding
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.ui.ScreenFragment
|
||||
import com.looker.droidify.utility.extension.screenActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class FavouritesFragment : ScreenFragment() {
|
||||
|
||||
private val viewModel: FavouritesViewModel by viewModels()
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
private lateinit var recyclerViewAdapter: FavouriteFragmentAdapter
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
val view = fragmentBinding.root.apply {
|
||||
val content = fragmentBinding.fragmentContent
|
||||
content.addView(
|
||||
RecyclerView(content.context).apply {
|
||||
id = android.R.id.list
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
isVerticalScrollBarEnabled = false
|
||||
setHasFixedSize(true)
|
||||
recyclerViewAdapter =
|
||||
FavouriteFragmentAdapter { screenActivity.navigateProduct(it) }
|
||||
this.adapter = recyclerViewAdapter
|
||||
systemBarsPadding(includeFab = false)
|
||||
recyclerView = this
|
||||
}
|
||||
)
|
||||
}
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
launch {
|
||||
viewModel.favouriteApps.collect { apps ->
|
||||
recyclerViewAdapter.apps = apps
|
||||
}
|
||||
}
|
||||
launch {
|
||||
Database.RepositoryAdapter
|
||||
.getAllStream()
|
||||
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.RESUMED)
|
||||
.collectLatest { repositories ->
|
||||
recyclerViewAdapter.repositories = repositories.associateBy { it.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toolbar.title = getString(CommonR.string.favourites)
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.looker.droidify.ui.favourites
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.looker.core.common.extension.asStateFlow
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.database.Database
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class FavouritesViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val favouriteApps: StateFlow<List<List<Product>>> =
|
||||
settingsRepository
|
||||
.get { favouriteApps }
|
||||
.map { favourites ->
|
||||
favourites.mapNotNull { app ->
|
||||
Database.ProductAdapter.get(app, null).ifEmpty { null }
|
||||
}
|
||||
}.asStateFlow(emptyList())
|
||||
|
||||
fun updateFavourites(packageName: String) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.toggleFavourites(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package com.looker.droidify.ui.repository
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.Selection
|
||||
import android.util.Base64
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.looker.core.common.extension.clipboardManager
|
||||
import com.looker.core.common.extension.get
|
||||
import com.looker.core.common.extension.getMutatedIcon
|
||||
import com.looker.core.common.nullIfEmpty
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.databinding.EditRepositoryBinding
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import com.looker.droidify.ui.Message
|
||||
import com.looker.droidify.ui.MessageDialog
|
||||
import com.looker.droidify.ui.ScreenFragment
|
||||
import com.looker.droidify.utility.extension.screenActivity
|
||||
import com.looker.network.Downloader
|
||||
import com.looker.network.NetworkResponse
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URL
|
||||
import java.nio.charset.Charset
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.min
|
||||
import com.looker.core.common.R as CommonR
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
|
||||
@AndroidEntryPoint
|
||||
class EditRepositoryFragment() : ScreenFragment() {
|
||||
|
||||
constructor(repositoryId: Long?, repoAddress: String?) : this() {
|
||||
arguments =
|
||||
bundleOf(EXTRA_REPOSITORY_ID to repositoryId, EXTRA_REPOSITORY_ADDRESS to repoAddress)
|
||||
}
|
||||
|
||||
private var _binding: EditRepositoryBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val repoId: Long?
|
||||
get() = arguments?.getLong(EXTRA_REPOSITORY_ID)
|
||||
|
||||
private val repoAddress: String?
|
||||
get() = arguments?.getString(EXTRA_REPOSITORY_ADDRESS)
|
||||
|
||||
private var saveMenuItem: MenuItem? = null
|
||||
|
||||
private val syncConnection = Connection(SyncService::class.java)
|
||||
private var checkInProgress = false
|
||||
private var checkJob: Job? = null
|
||||
|
||||
private var takenAddresses = emptySet<String>()
|
||||
|
||||
@Inject
|
||||
lateinit var downloader: Downloader
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
_binding = EditRepositoryBinding.inflate(layoutInflater)
|
||||
|
||||
syncConnection.bind(requireContext())
|
||||
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
toolbar.title =
|
||||
getString(
|
||||
if (repoId != null) stringRes.edit_repository else stringRes.add_repository
|
||||
)
|
||||
|
||||
saveMenuItem = toolbar.menu.add(stringRes.save)
|
||||
.setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_save))
|
||||
.setEnabled(false)
|
||||
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS).setOnMenuItemClickListener {
|
||||
onSaveRepositoryClick(true)
|
||||
true
|
||||
}
|
||||
|
||||
val content = fragmentBinding.fragmentContent
|
||||
|
||||
content.addView(binding.root)
|
||||
|
||||
val validChar: (Char) -> Boolean = { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||
|
||||
binding.fingerprint.doAfterTextChanged { text ->
|
||||
fun logicalPosition(text: String, position: Int): Int {
|
||||
return if (position > 0) {
|
||||
text.asSequence().take(position)
|
||||
.count(validChar)
|
||||
} else {
|
||||
position
|
||||
}
|
||||
}
|
||||
|
||||
fun realPosition(text: String, position: Int): Int {
|
||||
return if (position > 0) {
|
||||
var left = position
|
||||
val index = text.indexOfFirst {
|
||||
validChar(it) && run {
|
||||
left -= 1
|
||||
left <= 0
|
||||
}
|
||||
}
|
||||
if (index >= 0) min(index + 1, text.length) else text.length
|
||||
} else {
|
||||
position
|
||||
}
|
||||
}
|
||||
|
||||
val inputString = text.toString()
|
||||
val outputString = inputString
|
||||
.uppercase(Locale.US)
|
||||
.filter(validChar)
|
||||
.windowed(2, 2, true).take(32)
|
||||
.joinToString(separator = " ")
|
||||
if (inputString != outputString) {
|
||||
val inputStart = logicalPosition(inputString, Selection.getSelectionStart(text))
|
||||
val inputEnd = logicalPosition(inputString, Selection.getSelectionEnd(text))
|
||||
text?.replace(0, text.length, outputString)
|
||||
Selection.setSelection(
|
||||
text,
|
||||
realPosition(outputString, inputStart),
|
||||
realPosition(outputString, inputEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
val repository = repoId?.let(Database.RepositoryAdapter::get)
|
||||
if (repository == null) {
|
||||
val text = repoAddress ?: kotlin.run {
|
||||
context?.clipboardManager?.primaryClip?.takeIf { it.itemCount > 0 }
|
||||
?.getItemAt(0)?.text?.toString().orEmpty()
|
||||
}
|
||||
val (addressText, fingerprintText) = try {
|
||||
val uri = URL(text).toString().toUri()
|
||||
val fingerprintText = uri["fingerprint"]?.nullIfEmpty()
|
||||
?: uri["FINGERPRINT"]?.nullIfEmpty()
|
||||
Pair(
|
||||
uri.buildUpon().path(uri.path?.pathCropped).query(null).fragment(null)
|
||||
.build().toString(),
|
||||
fingerprintText
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Pair(null, null)
|
||||
}
|
||||
binding.address.setText(addressText)
|
||||
binding.fingerprint.setText(fingerprintText)
|
||||
} else {
|
||||
binding.address.setText(repository.address)
|
||||
val mirrors = repository.mirrors.map { it.withoutKnownPath }
|
||||
binding.addressContainer.apply {
|
||||
isEndIconVisible = mirrors.isNotEmpty()
|
||||
setEndIconDrawable(CommonR.drawable.ic_arrow_down)
|
||||
setEndIconOnClickListener {
|
||||
SelectMirrorDialog(mirrors).show(
|
||||
childFragmentManager,
|
||||
SelectMirrorDialog::class.java.name
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.fingerprint.setText(repository.fingerprint)
|
||||
val (usernameText, passwordText) = repository.authentication.nullIfEmpty()
|
||||
?.let { if (it.startsWith("Basic ")) it.substring(6) else null }?.let {
|
||||
try {
|
||||
Base64.decode(it, Base64.NO_WRAP).toString(Charset.defaultCharset())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}?.let {
|
||||
val index = it.indexOf(':')
|
||||
if (index >= 0) {
|
||||
Pair(
|
||||
it.substring(0, index),
|
||||
it.substring(index + 1)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} ?: Pair(null, null)
|
||||
binding.username.setText(usernameText)
|
||||
binding.password.setText(passwordText)
|
||||
}
|
||||
}
|
||||
|
||||
binding.address.doAfterTextChanged { invalidateAddress() }
|
||||
binding.fingerprint.doAfterTextChanged { invalidateFingerprint() }
|
||||
binding.username.doAfterTextChanged { invalidateUsernamePassword() }
|
||||
binding.password.doAfterTextChanged { invalidateUsernamePassword() }
|
||||
|
||||
(binding.overlay.parent as ViewGroup).layoutTransition?.setDuration(200L)
|
||||
binding.overlay.background!!.apply {
|
||||
mutate()
|
||||
alpha = 0xcc
|
||||
}
|
||||
binding.skip.setOnClickListener {
|
||||
if (checkInProgress) {
|
||||
checkInProgress = false
|
||||
checkJob?.cancel()
|
||||
onSaveRepositoryClick(false)
|
||||
}
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val list = Database.RepositoryAdapter.getAll()
|
||||
takenAddresses = list.asSequence().filter { it.id != repoId }
|
||||
.flatMap { (it.mirrors + it.address).asSequence() }
|
||||
.map { it.withoutKnownPath }
|
||||
.toSet()
|
||||
invalidateAddress()
|
||||
}
|
||||
invalidateAddress()
|
||||
invalidateFingerprint()
|
||||
invalidateUsernamePassword()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
saveMenuItem = null
|
||||
syncConnection.unbind(requireContext())
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private var addressError = false
|
||||
private var fingerprintError = false
|
||||
private var usernamePasswordError = false
|
||||
|
||||
private fun invalidateAddress() {
|
||||
invalidateAddress(binding.address.text.toString())
|
||||
}
|
||||
|
||||
private fun invalidateAddress(addressText: String) {
|
||||
val normalizedAddress = normalizeAddress(addressText)
|
||||
val addressErrorResId = if (normalizedAddress != null) {
|
||||
if (normalizedAddress.withoutKnownPath in takenAddresses) {
|
||||
stringRes.already_exists
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
stringRes.invalid_address
|
||||
}
|
||||
addressError = addressErrorResId != null
|
||||
addressErrorResId?.let { binding.address.error = getString(it) }
|
||||
invalidateState()
|
||||
}
|
||||
|
||||
private fun invalidateFingerprint() {
|
||||
val fingerprint = binding.fingerprint.text.toString().replace(" ", "")
|
||||
val fingerprintInvalid = fingerprint.isNotEmpty() && fingerprint.length != 64
|
||||
if (fingerprintInvalid) {
|
||||
binding.fingerprint.error = getString(stringRes.invalid_fingerprint_format)
|
||||
}
|
||||
fingerprintError = fingerprintInvalid
|
||||
invalidateState()
|
||||
}
|
||||
|
||||
private fun invalidateUsernamePassword() {
|
||||
val username = binding.username.text.toString()
|
||||
val password = binding.password.text.toString()
|
||||
val usernameInvalid = username.contains(':')
|
||||
val usernameEmpty = username.isEmpty() && password.isNotEmpty()
|
||||
val passwordEmpty = username.isNotEmpty() && password.isEmpty()
|
||||
if (usernameEmpty) {
|
||||
binding.username.error = getString(stringRes.username_missing)
|
||||
} else if (passwordEmpty) {
|
||||
binding.password.error = getString(stringRes.password_missing)
|
||||
} else if (usernameInvalid) {
|
||||
binding.username.error = getString(stringRes.invalid_username_format)
|
||||
}
|
||||
usernamePasswordError = usernameInvalid || usernameEmpty || passwordEmpty
|
||||
invalidateState()
|
||||
}
|
||||
|
||||
private fun invalidateState() {
|
||||
saveMenuItem!!.isEnabled =
|
||||
!addressError && !fingerprintError && !usernamePasswordError && !checkInProgress
|
||||
binding.apply {
|
||||
sequenceOf(address, fingerprint, username, password).forEach {
|
||||
it.isEnabled = !checkInProgress
|
||||
}
|
||||
}
|
||||
binding.overlay.isVisible = checkInProgress
|
||||
}
|
||||
|
||||
private val String.pathCropped: String
|
||||
get() {
|
||||
val index = indexOfLast { it != '/' }
|
||||
return if (index >= 0 && index < length - 1) substring(0, index + 1) else this
|
||||
}
|
||||
|
||||
private val String.withoutKnownPath: String
|
||||
get() {
|
||||
val cropped = pathCropped
|
||||
val endsWith =
|
||||
addressSuffixes.asSequence()
|
||||
.sortedByDescending { it.length }
|
||||
.find { cropped.endsWith("/$it") }
|
||||
return if (endsWith != null) {
|
||||
cropped.substring(
|
||||
0,
|
||||
cropped.length - endsWith.length - 1
|
||||
)
|
||||
} else {
|
||||
cropped
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeAddress(address: String): String? {
|
||||
val uri = try {
|
||||
val uri = URI(address)
|
||||
if (uri.isAbsolute) uri.normalize() else null
|
||||
} catch (e: URISyntaxException) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
uri?.toURL()?.toURI()?.toString()?.removeSuffix("/")
|
||||
} catch (e: URISyntaxException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMirror(address: String) {
|
||||
binding.address.setText(address)
|
||||
}
|
||||
|
||||
private fun onSaveRepositoryClick(check: Boolean) {
|
||||
if (!checkInProgress) {
|
||||
val address = normalizeAddress(binding.address.text.toString())!!
|
||||
val fingerprint = binding.fingerprint.text.toString().replace(" ", "")
|
||||
val username = binding.username.text.toString().nullIfEmpty()
|
||||
val password = binding.password.text.toString().nullIfEmpty()
|
||||
val authentication = username?.let { u ->
|
||||
password?.let { p ->
|
||||
Base64.encodeToString(
|
||||
"$u:$p".toByteArray(Charset.defaultCharset()),
|
||||
Base64.NO_WRAP
|
||||
)
|
||||
}
|
||||
}?.let { "Basic $it" }.orEmpty()
|
||||
|
||||
if (check) {
|
||||
checkJob = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
|
||||
val resultAddress = try {
|
||||
checkAddress(address, authentication)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
failedAddressCheck()
|
||||
null
|
||||
}
|
||||
val allow = resultAddress == address || run {
|
||||
if (resultAddress == null) return@run false
|
||||
binding.address.setText(resultAddress)
|
||||
invalidateAddress(resultAddress)
|
||||
!addressError
|
||||
}
|
||||
if (allow && resultAddress != null) {
|
||||
onSaveRepositoryProceedInvalidate(
|
||||
resultAddress,
|
||||
fingerprint,
|
||||
authentication
|
||||
)
|
||||
} else {
|
||||
invalidateState()
|
||||
}
|
||||
invalidateState()
|
||||
}
|
||||
} else {
|
||||
onSaveRepositoryProceedInvalidate(address, fingerprint, authentication)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkAddress(
|
||||
address: String,
|
||||
authentication: String
|
||||
): String? = coroutineScope {
|
||||
checkInProgress = true
|
||||
invalidateState()
|
||||
val allAddresses = addressSuffixes.map { "$address/$it" } + address
|
||||
val pathCheck = allAddresses.map {
|
||||
async {
|
||||
downloader.headCall(
|
||||
url = "$it/index-v1.jar",
|
||||
headers = { authentication(authentication) }
|
||||
) is NetworkResponse.Success
|
||||
}
|
||||
}
|
||||
val indexOfValidAddress = pathCheck.awaitAll().indexOf(true)
|
||||
allAddresses[indexOfValidAddress].nullIfEmpty()
|
||||
}
|
||||
|
||||
private fun onSaveRepositoryProceedInvalidate(
|
||||
address: String,
|
||||
fingerprint: String,
|
||||
authentication: String
|
||||
) {
|
||||
val binder = syncConnection.binder
|
||||
if (binder != null) {
|
||||
val repositoryId = repoId
|
||||
if (repositoryId != null && binder.isCurrentlySyncing(repositoryId)) {
|
||||
MessageDialog(Message.CantEditSyncing).show(childFragmentManager)
|
||||
invalidateState()
|
||||
} else {
|
||||
val repository = repositoryId?.let(Database.RepositoryAdapter::get)
|
||||
?.edit(address, fingerprint, authentication)
|
||||
?: Repository.newRepository(address, fingerprint, authentication)
|
||||
val changedRepository = Database.RepositoryAdapter.put(repository)
|
||||
if (repositoryId == null && changedRepository.enabled) {
|
||||
binder.sync(changedRepository)
|
||||
}
|
||||
screenActivity.onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
} else {
|
||||
invalidateState()
|
||||
}
|
||||
}
|
||||
|
||||
private fun failedAddressCheck() {
|
||||
checkInProgress = false
|
||||
invalidateState()
|
||||
Snackbar.make(
|
||||
requireView(),
|
||||
CommonR.string.repository_unreachable,
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
class SelectMirrorDialog() : DialogFragment() {
|
||||
constructor(mirrors: List<String>) : this() {
|
||||
arguments = bundleOf(EXTRA_MIRRORS to ArrayList(mirrors))
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
|
||||
val mirrors = requireArguments().getStringArrayList(EXTRA_MIRRORS)!!
|
||||
return MaterialAlertDialogBuilder(requireContext()).setTitle(stringRes.select_mirror)
|
||||
.setItems(mirrors.toTypedArray()) { _, position ->
|
||||
(parentFragment as EditRepositoryFragment).setMirror(mirrors[position])
|
||||
}.setNegativeButton(stringRes.cancel, null).create()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val EXTRA_MIRRORS = "mirrors"
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val EXTRA_REPOSITORY_ID = "repositoryId"
|
||||
const val EXTRA_REPOSITORY_ADDRESS = "repositoryAddress"
|
||||
|
||||
val addressSuffixes = listOf("fdroid/repo", "repo")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.looker.droidify.ui.repository
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.databinding.RepositoryItemBinding
|
||||
import com.looker.droidify.widget.CursorRecyclerAdapter
|
||||
|
||||
class RepositoriesAdapter(
|
||||
private val navigate: (Repository) -> Unit,
|
||||
private val onSwitch: (repository: Repository, isEnabled: Boolean) -> Boolean
|
||||
) : CursorRecyclerAdapter<RepositoriesAdapter.ViewType, RecyclerView.ViewHolder>() {
|
||||
enum class ViewType { REPOSITORY }
|
||||
|
||||
private class ViewHolder(itemView: RepositoryItemBinding) :
|
||||
RecyclerView.ViewHolder(itemView.root) {
|
||||
val checkMark = itemView.repositoryState
|
||||
val repoName = itemView.repositoryName
|
||||
val repoDesc = itemView.repositoryDescription
|
||||
|
||||
var isEnabled = true
|
||||
}
|
||||
|
||||
override val viewTypeClass: Class<ViewType>
|
||||
get() = ViewType::class.java
|
||||
|
||||
override fun getItemEnumViewType(position: Int): ViewType {
|
||||
return ViewType.REPOSITORY
|
||||
}
|
||||
|
||||
private fun getRepository(position: Int): Repository {
|
||||
return Database.RepositoryAdapter.transform(moveTo(position.takeUnless { it < 0 } ?: 0))
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: ViewType
|
||||
): RecyclerView.ViewHolder {
|
||||
return ViewHolder(
|
||||
RepositoryItemBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
).apply {
|
||||
itemView.setOnLongClickListener {
|
||||
navigate(getRepository(absoluteAdapterPosition))
|
||||
true
|
||||
}
|
||||
itemView.setOnClickListener {
|
||||
isEnabled = !isEnabled
|
||||
onSwitch(getRepository(absoluteAdapterPosition), isEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
holder as ViewHolder
|
||||
val repository = getRepository(position)
|
||||
|
||||
holder.isEnabled = repository.enabled
|
||||
holder.repoName.text = repository.name
|
||||
holder.repoDesc.text = repository.description.trim()
|
||||
|
||||
if (repository.enabled) {
|
||||
holder.checkMark.visibility = View.VISIBLE
|
||||
} else {
|
||||
holder.checkMark.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.looker.droidify.ui.repository
|
||||
|
||||
import android.database.Cursor
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.looker.core.common.R as CommonR
|
||||
import com.looker.core.common.extension.dp
|
||||
import com.looker.core.common.extension.systemBarsMargin
|
||||
import com.looker.core.common.extension.systemBarsPadding
|
||||
import com.looker.droidify.database.CursorOwner
|
||||
import com.looker.droidify.databinding.RecyclerViewWithFabBinding
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import com.looker.droidify.ui.ScreenFragment
|
||||
import com.looker.droidify.utility.extension.screenActivity
|
||||
import com.looker.droidify.widget.addDivider
|
||||
|
||||
class RepositoriesFragment : ScreenFragment(), CursorOwner.Callback {
|
||||
|
||||
private var _binding: RecyclerViewWithFabBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val syncConnection = Connection(SyncService::class.java)
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
_binding = RecyclerViewWithFabBinding.inflate(inflater, container, false)
|
||||
val view = fragmentBinding.root.apply {
|
||||
binding.scrollUp.apply {
|
||||
setIconResource(CommonR.drawable.ic_add)
|
||||
setText(CommonR.string.add_repository)
|
||||
setOnClickListener { screenActivity.navigateAddRepository() }
|
||||
systemBarsMargin(16.dp)
|
||||
}
|
||||
binding.recyclerView.apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
isMotionEventSplittingEnabled = false
|
||||
setHasFixedSize(true)
|
||||
adapter = RepositoriesAdapter(
|
||||
navigate = { screenActivity.navigateRepository(it.id) }
|
||||
) { repository, isEnabled ->
|
||||
repository.enabled != isEnabled &&
|
||||
syncConnection.binder?.setEnabled(repository, isEnabled) == true
|
||||
}
|
||||
addDivider { _, _, configuration ->
|
||||
configuration.set(
|
||||
needDivider = true,
|
||||
toTop = false,
|
||||
paddingStart = 16.dp,
|
||||
paddingEnd = 16.dp
|
||||
)
|
||||
}
|
||||
systemBarsPadding()
|
||||
}
|
||||
fragmentBinding.fragmentContent.addView(binding.root)
|
||||
}
|
||||
handleFab()
|
||||
return view
|
||||
}
|
||||
|
||||
private fun handleFab() {
|
||||
binding.recyclerView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
||||
if (scrollY > oldScrollY) {
|
||||
binding.scrollUp.shrink()
|
||||
} else {
|
||||
binding.scrollUp.extend()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
syncConnection.bind(requireContext())
|
||||
screenActivity.cursorOwner.attach(this, CursorOwner.Request.Repositories)
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
toolbar.title = getString(CommonR.string.repositories)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
_binding = null
|
||||
syncConnection.unbind(requireContext())
|
||||
screenActivity.cursorOwner.detach(this)
|
||||
}
|
||||
|
||||
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {
|
||||
(binding.recyclerView.adapter as RepositoriesAdapter).cursor = cursor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.looker.droidify.ui.repository
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.format.DateUtils
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.TypefaceSpan
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.looker.core.common.extension.getColorFromAttr
|
||||
import com.looker.core.common.extension.systemBarsPadding
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.databinding.RepositoryPageBinding
|
||||
import com.looker.droidify.ui.Message
|
||||
import com.looker.droidify.ui.MessageDialog
|
||||
import com.looker.droidify.ui.ScreenFragment
|
||||
import com.looker.droidify.utility.extension.screenActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import com.google.android.material.R as MaterialR
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RepositoryFragment() : ScreenFragment() {
|
||||
|
||||
private var _binding: RepositoryPageBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewModel: RepositoryViewModel by viewModels()
|
||||
|
||||
constructor(repositoryId: Long) : this() {
|
||||
arguments = bundleOf(RepositoryViewModel.ARG_REPO_ID to repositoryId)
|
||||
}
|
||||
|
||||
private var layout: LinearLayout? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
_binding = RepositoryPageBinding.inflate(inflater, container, false)
|
||||
viewModel.bindService(requireContext())
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
toolbar.title = getString(stringRes.repository)
|
||||
val scroll = NestedScrollView(binding.root.context)
|
||||
scroll.addView(binding.root)
|
||||
scroll.systemBarsPadding()
|
||||
fragmentBinding.fragmentContent.addView(scroll)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
viewModel.state.collectLatest {
|
||||
setupView(it.repo, it.appCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fragmentBinding.root
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
layout = null
|
||||
viewModel.unbindService(requireContext())
|
||||
}
|
||||
|
||||
private fun setupView(repository: Repository?, appCount: Int) {
|
||||
with(binding) {
|
||||
address.title.setText(stringRes.address)
|
||||
if (repository == null) {
|
||||
address.text.text = getString(stringRes.unknown)
|
||||
} else {
|
||||
repoSwitch.isChecked = repository.enabled
|
||||
repoSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
viewModel.enabledRepository(isChecked)
|
||||
}
|
||||
|
||||
address.text.text = repository.address
|
||||
toolbar.title = repository.name
|
||||
repoName.title.setText(stringRes.name)
|
||||
repoName.text.text = repository.name
|
||||
|
||||
repoDescription.title.setText(stringRes.description)
|
||||
repoDescription.text.text = repository.description.replace('\n', ' ').trim()
|
||||
|
||||
recentlyUpdated.title.setText(stringRes.recently_updated)
|
||||
recentlyUpdated.text.text = run {
|
||||
val lastUpdated = repository.updated
|
||||
if (lastUpdated > 0L) {
|
||||
val date = Date(repository.updated)
|
||||
val format =
|
||||
if (DateUtils.isToday(date.time)) {
|
||||
DateUtils.FORMAT_SHOW_TIME
|
||||
} else {
|
||||
DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE
|
||||
}
|
||||
DateUtils.formatDateTime(requireContext(), date.time, format)
|
||||
} else {
|
||||
getString(stringRes.unknown)
|
||||
}
|
||||
}
|
||||
|
||||
numberOfApps.title.setText(stringRes.number_of_applications)
|
||||
numberOfApps.text.text = appCount.toString()
|
||||
|
||||
repoFingerprint.title.setText(stringRes.fingerprint)
|
||||
if (repository.fingerprint.isEmpty()) {
|
||||
if (repository.updated > 0L) {
|
||||
val builder =
|
||||
SpannableStringBuilder(getString(stringRes.repository_unsigned_DESC))
|
||||
builder.setSpan(
|
||||
ForegroundColorSpan(
|
||||
requireContext()
|
||||
.getColorFromAttr(MaterialR.attr.colorError)
|
||||
.defaultColor
|
||||
),
|
||||
0,
|
||||
builder.length,
|
||||
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
repoFingerprint.text.text = builder
|
||||
}
|
||||
} else {
|
||||
val fingerprint =
|
||||
SpannableStringBuilder(
|
||||
repository.fingerprint.windowed(2, 2, false)
|
||||
.take(32).joinToString(separator = " ") { it.uppercase(Locale.US) }
|
||||
)
|
||||
fingerprint.setSpan(
|
||||
TypefaceSpan("monospace"),
|
||||
0,
|
||||
fingerprint.length,
|
||||
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
repoFingerprint.text.text = fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
editRepoButton.setOnClickListener {
|
||||
screenActivity.navigateEditRepository(viewModel.id)
|
||||
}
|
||||
|
||||
deleteRepoButton.setOnClickListener {
|
||||
MessageDialog(
|
||||
Message.DeleteRepositoryConfirm
|
||||
).show(childFragmentManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onDeleteConfirm() {
|
||||
viewModel.deleteRepository(
|
||||
onDelete = { requireActivity().onBackPressedDispatcher.onBackPressed() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.looker.droidify.ui.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.looker.core.common.extension.asStateFlow
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class RepositoryViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
val id: Long = savedStateHandle[ARG_REPO_ID] ?: -1
|
||||
|
||||
private val repoStream = Database.RepositoryAdapter.getStream(id)
|
||||
|
||||
private val countStream = Database.ProductAdapter.getCountStream(id)
|
||||
|
||||
val state = combine(repoStream, countStream) { repo, count ->
|
||||
RepositoryPageItem(repo, count)
|
||||
}.asStateFlow(RepositoryPageItem())
|
||||
|
||||
private val syncConnection = Connection(SyncService::class.java)
|
||||
|
||||
fun bindService(context: Context) {
|
||||
syncConnection.bind(context)
|
||||
}
|
||||
|
||||
fun unbindService(context: Context) {
|
||||
syncConnection.unbind(context)
|
||||
}
|
||||
|
||||
fun enabledRepository(enable: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val repo = repoStream.first { it != null }!!
|
||||
syncConnection.binder?.setEnabled(repo, enable)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRepository(onDelete: () -> Unit) {
|
||||
if (syncConnection.binder?.deleteRepository(id) == true) {
|
||||
onDelete()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ARG_REPO_ID = "repo_id"
|
||||
}
|
||||
}
|
||||
|
||||
data class RepositoryPageItem(
|
||||
val repo: Repository? = null,
|
||||
val appCount: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,554 @@
|
||||
package com.looker.droidify.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||
import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.looker.core.common.SdkCheck
|
||||
import com.looker.core.common.extension.getColorFromAttr
|
||||
import com.looker.core.common.extension.homeAsUp
|
||||
import com.looker.core.common.extension.systemBarsPadding
|
||||
import com.looker.core.common.extension.updateAsMutable
|
||||
import com.looker.core.common.isIgnoreBatteryEnabled
|
||||
import com.looker.core.common.requestBatteryFreedom
|
||||
import com.looker.core.datastore.Settings
|
||||
import com.looker.core.datastore.extension.autoSyncName
|
||||
import com.looker.core.datastore.extension.installerName
|
||||
import com.looker.core.datastore.extension.proxyName
|
||||
import com.looker.core.datastore.extension.themeName
|
||||
import com.looker.core.datastore.extension.toTime
|
||||
import com.looker.core.datastore.model.AutoSync
|
||||
import com.looker.core.datastore.model.InstallerType
|
||||
import com.looker.core.datastore.model.ProxyType
|
||||
import com.looker.core.datastore.model.Theme
|
||||
import com.looker.droidify.BuildConfig
|
||||
import com.looker.droidify.databinding.EnumTypeBinding
|
||||
import com.looker.droidify.databinding.SettingsPageBinding
|
||||
import com.looker.droidify.databinding.SwitchTypeBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import com.google.android.material.R as MaterialR
|
||||
import com.looker.core.common.BuildConfig as CommonBuildConfig
|
||||
import com.looker.core.common.R as CommonR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SettingsFragment : Fragment() {
|
||||
|
||||
companion object {
|
||||
fun newInstance() = SettingsFragment()
|
||||
|
||||
private const val BACKUP_MIME_TYPE = "application/json"
|
||||
private const val REPO_BACKUP_NAME = "droidify_repos"
|
||||
private const val SETTINGS_BACKUP_NAME = "droidify_settings"
|
||||
|
||||
private val localeCodesList: List<String> = CommonBuildConfig.DETECTED_LOCALES
|
||||
.toList()
|
||||
.updateAsMutable { add(0, "system") }
|
||||
|
||||
private const val FOXY_DROID_TITLE = "FoxyDroid"
|
||||
private const val FOXY_DROID_URL = "https://github.com/kitsunyan/foxy-droid"
|
||||
|
||||
private const val DROID_IFY_TITLE = "Droid-ify"
|
||||
private const val DROID_IFY_URL = "https://github.com/Iamlooker/Droid-ify"
|
||||
}
|
||||
|
||||
private val viewModel: SettingsViewModel by viewModels()
|
||||
private var _binding: SettingsPageBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val createExportFileForSettings =
|
||||
registerForActivityResult(CreateDocument(BACKUP_MIME_TYPE)) { fileUri ->
|
||||
if (fileUri != null) {
|
||||
viewModel.exportSettings(fileUri)
|
||||
}
|
||||
}
|
||||
|
||||
private val openImportFileForSettings =
|
||||
registerForActivityResult(OpenDocument()) { fileUri ->
|
||||
if (fileUri != null) {
|
||||
viewModel.importSettings(fileUri)
|
||||
} else {
|
||||
viewModel.createSnackbar(CommonR.string.file_format_error_DESC)
|
||||
}
|
||||
}
|
||||
|
||||
private val createExportFileForRepos =
|
||||
registerForActivityResult(CreateDocument(BACKUP_MIME_TYPE)) { fileUri ->
|
||||
if (fileUri != null) {
|
||||
viewModel.exportRepos(fileUri)
|
||||
}
|
||||
}
|
||||
|
||||
private val openImportFileForRepos =
|
||||
registerForActivityResult(OpenDocument()) { fileUri ->
|
||||
if (fileUri != null) {
|
||||
viewModel.importRepos(fileUri)
|
||||
} else {
|
||||
viewModel.createSnackbar(CommonR.string.file_format_error_DESC)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = SettingsPageBinding.inflate(inflater, container, false)
|
||||
binding.nestedScrollView.systemBarsPadding()
|
||||
if (requireContext().isIgnoreBatteryEnabled()) {
|
||||
viewModel.allowBackground()
|
||||
}
|
||||
val toolbar = binding.toolbar
|
||||
toolbar.navigationIcon = toolbar.context.homeAsUp
|
||||
toolbar.setNavigationOnClickListener { activity?.onBackPressedDispatcher?.onBackPressed() }
|
||||
toolbar.title = getString(CommonR.string.settings)
|
||||
with(binding) {
|
||||
dynamicTheme.root.isVisible = SdkCheck.isSnowCake
|
||||
dynamicTheme.connect(
|
||||
titleText = getString(CommonR.string.material_you),
|
||||
contentText = getString(CommonR.string.material_you_desc),
|
||||
setting = viewModel.getInitialSetting { dynamicTheme }
|
||||
)
|
||||
homeScreenSwiping.connect(
|
||||
titleText = getString(CommonR.string.home_screen_swiping),
|
||||
contentText = getString(CommonR.string.home_screen_swiping_DESC),
|
||||
setting = viewModel.getInitialSetting { homeScreenSwiping }
|
||||
)
|
||||
autoUpdate.connect(
|
||||
titleText = getString(CommonR.string.auto_update),
|
||||
contentText = getString(CommonR.string.auto_update_apps),
|
||||
setting = viewModel.getInitialSetting { autoUpdate }
|
||||
)
|
||||
notifyUpdates.connect(
|
||||
titleText = getString(CommonR.string.notify_about_updates),
|
||||
contentText = getString(CommonR.string.notify_about_updates_summary),
|
||||
setting = viewModel.getInitialSetting { notifyUpdate }
|
||||
)
|
||||
unstableUpdates.connect(
|
||||
titleText = getString(CommonR.string.unstable_updates),
|
||||
contentText = getString(CommonR.string.unstable_updates_summary),
|
||||
setting = viewModel.getInitialSetting { unstableUpdate }
|
||||
)
|
||||
ignoreSignature.connect(
|
||||
titleText = getString(CommonR.string.ignore_signature),
|
||||
contentText = getString(CommonR.string.ignore_signature_summary),
|
||||
setting = viewModel.getInitialSetting { ignoreSignature }
|
||||
)
|
||||
incompatibleUpdates.connect(
|
||||
titleText = getString(CommonR.string.incompatible_versions),
|
||||
contentText = getString(CommonR.string.incompatible_versions_summary),
|
||||
setting = viewModel.getInitialSetting { incompatibleVersions }
|
||||
)
|
||||
language.connect(
|
||||
titleText = getString(CommonR.string.prefs_language_title),
|
||||
map = { translateLocale(getLocaleOfCode(it)) },
|
||||
setting = viewModel.getSetting { language }
|
||||
) { selectedLocale, valueToString ->
|
||||
addSingleCorrectDialog(
|
||||
initialValue = selectedLocale,
|
||||
values = localeCodesList,
|
||||
title = CommonR.string.prefs_language_title,
|
||||
iconRes = CommonR.drawable.ic_language,
|
||||
valueToString = valueToString,
|
||||
onClick = viewModel::setLanguage
|
||||
)
|
||||
}
|
||||
theme.connect(
|
||||
titleText = getString(CommonR.string.theme),
|
||||
setting = viewModel.getSetting { theme },
|
||||
map = { themeName(it) }
|
||||
) { theme, valueToString ->
|
||||
addSingleCorrectDialog(
|
||||
initialValue = theme,
|
||||
values = Theme.entries,
|
||||
title = CommonR.string.themes,
|
||||
iconRes = CommonR.drawable.ic_themes,
|
||||
valueToString = valueToString,
|
||||
onClick = viewModel::setTheme
|
||||
)
|
||||
}
|
||||
cleanUp.connect(
|
||||
titleText = getString(CommonR.string.cleanup_title),
|
||||
setting = viewModel.getSetting { cleanUpInterval },
|
||||
map = { toTime(it) }
|
||||
) { duration, valueToString ->
|
||||
addSingleCorrectDialog(
|
||||
initialValue = duration,
|
||||
values = cleanUpIntervals,
|
||||
title = CommonR.string.cleanup_title,
|
||||
iconRes = CommonR.drawable.ic_time,
|
||||
valueToString = valueToString,
|
||||
onClick = viewModel::setCleanUpInterval
|
||||
)
|
||||
}
|
||||
autoSync.connect(
|
||||
titleText = getString(CommonR.string.sync_repositories_automatically),
|
||||
setting = viewModel.getSetting { autoSync },
|
||||
map = { autoSyncName(it) }
|
||||
) { autoSync, valueToString ->
|
||||
addSingleCorrectDialog(
|
||||
initialValue = autoSync,
|
||||
values = AutoSync.entries,
|
||||
title = CommonR.string.sync_repositories_automatically,
|
||||
iconRes = CommonR.drawable.ic_sync_type,
|
||||
valueToString = valueToString,
|
||||
onClick = viewModel::setAutoSync
|
||||
)
|
||||
}
|
||||
installer.connect(
|
||||
titleText = getString(CommonR.string.installer),
|
||||
setting = viewModel.getSetting { installerType },
|
||||
map = { installerName(it) }
|
||||
) { installerType, valueToString ->
|
||||
addSingleCorrectDialog(
|
||||
initialValue = installerType,
|
||||
values = InstallerType.entries,
|
||||
title = CommonR.string.installer,
|
||||
iconRes = CommonR.drawable.ic_apk_install,
|
||||
valueToString = valueToString,
|
||||
onClick = viewModel::setInstaller
|
||||
)
|
||||
}
|
||||
proxyType.connect(
|
||||
titleText = getString(CommonR.string.proxy_type),
|
||||
setting = viewModel.getSetting { proxy.type },
|
||||
map = { proxyName(it) }
|
||||
) { proxyType, valueToString ->
|
||||
addSingleCorrectDialog(
|
||||
initialValue = proxyType,
|
||||
values = ProxyType.entries,
|
||||
title = CommonR.string.proxy_type,
|
||||
iconRes = CommonR.drawable.ic_proxy,
|
||||
valueToString = valueToString,
|
||||
onClick = viewModel::setProxyType
|
||||
)
|
||||
}
|
||||
proxyHost.connect(
|
||||
titleText = getString(CommonR.string.proxy_host),
|
||||
setting = viewModel.getSetting { proxy.host },
|
||||
map = { it }
|
||||
) { host, _ ->
|
||||
addEditTextDialog(
|
||||
initialValue = host,
|
||||
title = CommonR.string.proxy_host,
|
||||
onFinish = viewModel::setProxyHost
|
||||
)
|
||||
}
|
||||
proxyPort.connect(
|
||||
titleText = getString(CommonR.string.proxy_port),
|
||||
setting = viewModel.getSetting { proxy.port },
|
||||
map = { it.toString() }
|
||||
) { port, _ ->
|
||||
addEditTextDialog(
|
||||
initialValue = port.toString(),
|
||||
title = CommonR.string.proxy_port,
|
||||
onFinish = viewModel::setProxyPort
|
||||
)
|
||||
}
|
||||
|
||||
forceCleanUp.title.text = getString(CommonR.string.force_clean_up)
|
||||
forceCleanUp.content.text = getString(CommonR.string.force_clean_up_DESC)
|
||||
|
||||
importSettings.title.text = getString(CommonR.string.import_settings_title)
|
||||
importSettings.content.text = getString(CommonR.string.import_settings_DESC)
|
||||
exportSettings.title.text = getString(CommonR.string.export_settings_title)
|
||||
exportSettings.content.text = getString(CommonR.string.export_settings_DESC)
|
||||
|
||||
importRepos.title.text = getString(CommonR.string.import_repos_title)
|
||||
importRepos.content.text = getString(CommonR.string.import_repos_DESC)
|
||||
exportRepos.title.text = getString(CommonR.string.export_repos_title)
|
||||
exportRepos.content.text = getString(CommonR.string.export_repos_DESC)
|
||||
|
||||
allowBackgroundWork.title.text = getString(CommonR.string.require_background_access)
|
||||
allowBackgroundWork.content.text =
|
||||
getString(CommonR.string.require_background_access_DESC)
|
||||
allowBackgroundWork.root.setBackgroundColor(
|
||||
requireContext()
|
||||
.getColorFromAttr(MaterialR.attr.colorErrorContainer)
|
||||
.defaultColor
|
||||
)
|
||||
allowBackgroundWork.title.setTextColor(
|
||||
requireContext()
|
||||
.getColorFromAttr(MaterialR.attr.colorOnErrorContainer)
|
||||
)
|
||||
allowBackgroundWork.content.setTextColor(
|
||||
requireContext()
|
||||
.getColorFromAttr(MaterialR.attr.colorOnErrorContainer)
|
||||
)
|
||||
creditFoxy.title.text = getString(CommonR.string.special_credits)
|
||||
creditFoxy.content.text = FOXY_DROID_TITLE
|
||||
droidify.title.text = DROID_IFY_TITLE
|
||||
droidify.content.text = BuildConfig.VERSION_NAME
|
||||
}
|
||||
setChangeListener()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
launch {
|
||||
viewModel.snackbarStringId.collect {
|
||||
Snackbar.make(binding.root, it, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
viewModel.settingsFlow.collect { setting ->
|
||||
updateSettings(setting)
|
||||
binding.allowBackgroundWork.root.isVisible = !viewModel.backgroundTask.first()
|
||||
&& setting.autoSync != AutoSync.NEVER
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (requireContext().isIgnoreBatteryEnabled()) {
|
||||
viewModel.allowBackground()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private fun setChangeListener() {
|
||||
with(binding) {
|
||||
dynamicTheme.checked.setOnCheckedChangeListener { _, checked ->
|
||||
viewModel.setDynamicTheme(checked)
|
||||
}
|
||||
homeScreenSwiping.checked.setOnCheckedChangeListener { _, checked ->
|
||||
viewModel.setHomeScreenSwiping(checked)
|
||||
}
|
||||
notifyUpdates.checked.setOnCheckedChangeListener { _, checked ->
|
||||
viewModel.setNotifyUpdates(checked)
|
||||
}
|
||||
autoUpdate.checked.setOnCheckedChangeListener { _, checked ->
|
||||
viewModel.setAutoUpdate(checked)
|
||||
}
|
||||
unstableUpdates.checked.setOnCheckedChangeListener { _, checked ->
|
||||
viewModel.setUnstableUpdates(checked)
|
||||
}
|
||||
ignoreSignature.checked.setOnCheckedChangeListener { _, checked ->
|
||||
viewModel.setIgnoreSignature(checked)
|
||||
}
|
||||
incompatibleUpdates.checked.setOnCheckedChangeListener { _, checked ->
|
||||
viewModel.setIncompatibleUpdates(checked)
|
||||
}
|
||||
forceCleanUp.root.setOnClickListener {
|
||||
viewModel.forceCleanup(it.context)
|
||||
}
|
||||
importSettings.root.setOnClickListener {
|
||||
openImportFileForSettings.launch(arrayOf(BACKUP_MIME_TYPE))
|
||||
}
|
||||
exportSettings.root.setOnClickListener {
|
||||
createExportFileForSettings.launch(SETTINGS_BACKUP_NAME)
|
||||
}
|
||||
importRepos.root.setOnClickListener {
|
||||
openImportFileForRepos.launch(arrayOf(BACKUP_MIME_TYPE))
|
||||
}
|
||||
exportRepos.root.setOnClickListener {
|
||||
createExportFileForRepos.launch(REPO_BACKUP_NAME)
|
||||
}
|
||||
allowBackgroundWork.root.setOnClickListener {
|
||||
requireContext().requestBatteryFreedom()
|
||||
if (requireContext().isIgnoreBatteryEnabled()) {
|
||||
viewModel.allowBackground()
|
||||
}
|
||||
}
|
||||
creditFoxy.root.setOnClickListener {
|
||||
openLink(FOXY_DROID_URL)
|
||||
}
|
||||
droidify.root.setOnClickListener {
|
||||
openLink(DROID_IFY_URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSettings(settings: Settings) {
|
||||
with(binding) {
|
||||
val allowProxies = settings.proxy.type != ProxyType.DIRECT
|
||||
proxyHost.root.isVisible = allowProxies
|
||||
proxyPort.root.isVisible = allowProxies
|
||||
forceCleanUp.root.isVisible = settings.cleanUpInterval == Duration.INFINITE
|
||||
}
|
||||
}
|
||||
|
||||
private val cleanUpIntervals =
|
||||
listOf(6.hours, 12.hours, 18.hours, 1.days, 2.days, Duration.INFINITE)
|
||||
|
||||
private fun translateLocale(locale: Locale?): String {
|
||||
val country = locale?.getDisplayCountry(locale)
|
||||
val language = locale?.getDisplayLanguage(locale)
|
||||
val languageDisplay = if (locale != null) {
|
||||
(
|
||||
language?.replaceFirstChar { it.uppercase(Locale.getDefault()) } +
|
||||
(
|
||||
if (country?.isNotEmpty() == true && country.compareTo(
|
||||
language.toString(),
|
||||
true
|
||||
) != 0
|
||||
) {
|
||||
"($country)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
getString(CommonR.string.system)
|
||||
}
|
||||
return languageDisplay
|
||||
}
|
||||
|
||||
private fun openLink(link: String) {
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link)))
|
||||
} catch (e: IllegalStateException) {
|
||||
viewModel.createSnackbar(CommonR.string.cannot_open_link)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun Context.getLocaleOfCode(localeCode: String): Locale? = when {
|
||||
localeCode.isEmpty() -> if (SdkCheck.isNougat) {
|
||||
resources.configuration.locales[0]
|
||||
} else {
|
||||
resources.configuration.locale
|
||||
}
|
||||
|
||||
localeCode.contains("-r") -> Locale(
|
||||
localeCode.substring(0, 2),
|
||||
localeCode.substring(4)
|
||||
)
|
||||
|
||||
localeCode.contains("_") -> Locale(
|
||||
localeCode.substring(0, 2),
|
||||
localeCode.substring(3)
|
||||
)
|
||||
|
||||
localeCode == "system" -> null
|
||||
else -> Locale(localeCode)
|
||||
}
|
||||
|
||||
private fun <T> EnumTypeBinding.connect(
|
||||
titleText: String,
|
||||
setting: Flow<T>,
|
||||
map: Context.(T) -> String,
|
||||
dialog: View.(T, valueToString: Context.(T) -> String) -> AlertDialog
|
||||
) {
|
||||
title.text = titleText
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
setting.collect {
|
||||
with(root.context) {
|
||||
content.text = map(it)
|
||||
}
|
||||
root.setOnClickListener { _ ->
|
||||
root.dialog(it, map).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SwitchTypeBinding.connect(
|
||||
titleText: String,
|
||||
contentText: String,
|
||||
setting: Flow<Boolean>
|
||||
) {
|
||||
title.text = titleText
|
||||
content.text = contentText
|
||||
root.setOnClickListener {
|
||||
checked.isChecked = !checked.isChecked
|
||||
}
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
setting.collect {
|
||||
checked.isChecked = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> View.addSingleCorrectDialog(
|
||||
initialValue: T,
|
||||
values: List<T>,
|
||||
@StringRes title: Int,
|
||||
@DrawableRes iconRes: Int,
|
||||
onClick: (T) -> Unit,
|
||||
valueToString: Context.(T) -> String
|
||||
) = MaterialAlertDialogBuilder(context)
|
||||
.setTitle(title)
|
||||
.setIcon(iconRes)
|
||||
.setSingleChoiceItems(
|
||||
values.map { context.valueToString(it) }.toTypedArray(),
|
||||
values.indexOf(initialValue)
|
||||
) { dialog, newValue ->
|
||||
dialog.dismiss()
|
||||
post {
|
||||
onClick(values.elementAt(newValue))
|
||||
}
|
||||
}
|
||||
.setNegativeButton(CommonR.string.cancel, null)
|
||||
.create()
|
||||
|
||||
private fun View.addEditTextDialog(
|
||||
initialValue: String,
|
||||
@StringRes title: Int,
|
||||
onFinish: (String) -> Unit
|
||||
): AlertDialog {
|
||||
val scroll = NestedScrollView(context)
|
||||
val customEditText = TextInputEditText(context)
|
||||
customEditText.id = android.R.id.edit
|
||||
val paddingValue = context.resources.getDimension(CommonR.dimen.shape_margin_large).toInt()
|
||||
scroll.setPadding(paddingValue, 0, paddingValue, 0)
|
||||
customEditText.setText(initialValue)
|
||||
customEditText.hint = customEditText.text.toString()
|
||||
customEditText.text?.let { editable -> customEditText.setSelection(editable.length) }
|
||||
customEditText.requestFocus()
|
||||
scroll.addView(
|
||||
customEditText,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
return MaterialAlertDialogBuilder(context)
|
||||
.setTitle(title)
|
||||
.setView(scroll)
|
||||
.setPositiveButton(CommonR.string.ok) { _, _ ->
|
||||
post { onFinish(customEditText.text.toString()) }
|
||||
}
|
||||
.setNegativeButton(CommonR.string.cancel, null)
|
||||
.create()
|
||||
.apply {
|
||||
window!!.setSoftInputMode(
|
||||
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.looker.droidify.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.looker.core.common.extension.toLocale
|
||||
import com.looker.core.datastore.Settings
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.core.datastore.model.AutoSync
|
||||
import com.looker.core.datastore.model.InstallerType
|
||||
import com.looker.core.datastore.model.ProxyType
|
||||
import com.looker.core.datastore.model.Theme
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.database.RepositoryExporter
|
||||
import com.looker.droidify.work.CleanUpWorker
|
||||
import com.looker.installer.installers.shizuku.ShizukuPermissionHandler
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
import com.looker.core.common.R as CommonR
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel
|
||||
@Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val shizukuPermissionHandler: ShizukuPermissionHandler,
|
||||
private val repositoryExporter: RepositoryExporter
|
||||
) : ViewModel() {
|
||||
|
||||
private val initialSetting = flow {
|
||||
emit(settingsRepository.getInitial())
|
||||
}
|
||||
val settingsFlow get() = settingsRepository.data
|
||||
|
||||
private val _backgroundTask = MutableStateFlow(false)
|
||||
val backgroundTask = _backgroundTask.asStateFlow()
|
||||
|
||||
private val _snackbarStringId = MutableSharedFlow<Int>()
|
||||
val snackbarStringId = _snackbarStringId.asSharedFlow()
|
||||
|
||||
fun <T> getSetting(block: Settings.() -> T): Flow<T> = settingsRepository.get(block)
|
||||
|
||||
fun <T> getInitialSetting(block: Settings.() -> T): Flow<T> = initialSetting.map { it.block() }
|
||||
|
||||
fun allowBackground() {
|
||||
viewModelScope.launch {
|
||||
_backgroundTask.emit(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun setLanguage(language: String) {
|
||||
viewModelScope.launch {
|
||||
val appLocale = LocaleListCompat.create(language.toLocale())
|
||||
AppCompatDelegate.setApplicationLocales(appLocale)
|
||||
settingsRepository.setLanguage(language)
|
||||
}
|
||||
}
|
||||
|
||||
fun setTheme(theme: Theme) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDynamicTheme(enable: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setDynamicTheme(enable)
|
||||
}
|
||||
}
|
||||
|
||||
fun setHomeScreenSwiping(enable: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setHomeScreenSwiping(enable)
|
||||
}
|
||||
}
|
||||
|
||||
fun setCleanUpInterval(interval: Duration) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setCleanUpInterval(interval)
|
||||
}
|
||||
}
|
||||
|
||||
fun forceCleanup(context: Context) {
|
||||
viewModelScope.launch {
|
||||
CleanUpWorker.force(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoSync(autoSync: AutoSync) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setAutoSync(autoSync)
|
||||
}
|
||||
}
|
||||
|
||||
fun setNotifyUpdates(enable: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.enableNotifyUpdates(enable)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoUpdate(enable: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setAutoUpdate(enable)
|
||||
}
|
||||
}
|
||||
|
||||
fun setUnstableUpdates(enable: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.enableUnstableUpdates(enable)
|
||||
}
|
||||
}
|
||||
|
||||
fun setIgnoreSignature(enable: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setIgnoreSignature(enable)
|
||||
}
|
||||
}
|
||||
|
||||
fun setIncompatibleUpdates(enable: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.enableIncompatibleVersion(enable)
|
||||
}
|
||||
}
|
||||
|
||||
fun setProxyType(proxyType: ProxyType) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setProxyType(proxyType)
|
||||
}
|
||||
}
|
||||
|
||||
fun setProxyHost(proxyHost: String) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setProxyHost(proxyHost)
|
||||
}
|
||||
}
|
||||
|
||||
fun setProxyPort(proxyPort: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
settingsRepository.setProxyPort(proxyPort.toInt())
|
||||
} catch (e: NumberFormatException) {
|
||||
createSnackbar(CommonR.string.proxy_port_error_not_int)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setInstaller(installerType: InstallerType) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setInstallerType(installerType)
|
||||
if (installerType == InstallerType.SHIZUKU) handleShizuku()
|
||||
}
|
||||
}
|
||||
|
||||
fun exportSettings(file: Uri) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.export(file)
|
||||
}
|
||||
}
|
||||
|
||||
fun importSettings(file: Uri) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.import(file)
|
||||
}
|
||||
}
|
||||
|
||||
fun exportRepos(file: Uri) {
|
||||
viewModelScope.launch {
|
||||
val repos = Database.RepositoryAdapter.getAll()
|
||||
repositoryExporter.export(repos, file)
|
||||
}
|
||||
}
|
||||
|
||||
fun importRepos(file: Uri) {
|
||||
viewModelScope.launch {
|
||||
val repos = repositoryExporter.import(file)
|
||||
Database.RepositoryAdapter.importRepos(repos)
|
||||
}
|
||||
}
|
||||
|
||||
fun createSnackbar(@StringRes message: Int) {
|
||||
viewModelScope.launch {
|
||||
_snackbarStringId.emit(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleShizuku() {
|
||||
viewModelScope.launch {
|
||||
val state = shizukuPermissionHandler.state.first()
|
||||
if (state.isAlive && state.isPermissionGranted) cancel()
|
||||
if (state.isInstalled) {
|
||||
if (!state.isAlive) {
|
||||
createSnackbar(CommonR.string.shizuku_not_alive)
|
||||
}
|
||||
} else {
|
||||
createSnackbar(CommonR.string.shizuku_not_installed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
package com.looker.droidify.ui.tabsFragment
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.elevation.SurfaceColors
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.looker.core.common.device.Huawei
|
||||
import com.looker.core.common.extension.dp
|
||||
import com.looker.core.common.extension.getMutatedIcon
|
||||
import com.looker.core.common.extension.selectableBackground
|
||||
import com.looker.core.common.extension.systemBarsPadding
|
||||
import com.looker.core.common.sdkAbove
|
||||
import com.looker.core.datastore.extension.sortOrderName
|
||||
import com.looker.core.datastore.model.SortOrder
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.databinding.TabsToolbarBinding
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import com.looker.droidify.ui.ScreenFragment
|
||||
import com.looker.droidify.ui.appList.AppListFragment
|
||||
import com.looker.droidify.utility.extension.resources.sizeScaled
|
||||
import com.looker.droidify.utility.extension.screenActivity
|
||||
import com.looker.droidify.widget.DividerConfiguration
|
||||
import com.looker.droidify.widget.FocusSearchView
|
||||
import com.looker.droidify.widget.StableRecyclerAdapter
|
||||
import com.looker.droidify.widget.addDivider
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import com.looker.core.common.R as CommonR
|
||||
import com.looker.core.common.R.string as stringRes
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TabsFragment : ScreenFragment() {
|
||||
|
||||
enum class BackAction {
|
||||
ProductAll,
|
||||
CollapseSearchView,
|
||||
HideSections,
|
||||
None,
|
||||
}
|
||||
|
||||
private var _tabsBinding: TabsToolbarBinding? = null
|
||||
private val tabsBinding get() = _tabsBinding!!
|
||||
|
||||
private val viewModel: TabsViewModel by viewModels()
|
||||
|
||||
companion object {
|
||||
private const val STATE_SEARCH_FOCUSED = "searchFocused"
|
||||
private const val STATE_SEARCH_QUERY = "searchQuery"
|
||||
private const val STATE_SHOW_SECTIONS = "showSections"
|
||||
}
|
||||
|
||||
private class Layout(view: TabsToolbarBinding) {
|
||||
val tabs = view.tabs
|
||||
val sectionLayout = view.sectionLayout
|
||||
val sectionChange = view.sectionChange
|
||||
val sectionName = view.sectionName
|
||||
val sectionIcon = view.sectionIcon
|
||||
}
|
||||
|
||||
private var favouritesItem: MenuItem? = null
|
||||
private var searchMenuItem: MenuItem? = null
|
||||
private var sortOrderMenu: Pair<MenuItem, List<MenuItem>>? = null
|
||||
private var syncRepositoriesMenuItem: MenuItem? = null
|
||||
private var layout: Layout? = null
|
||||
private var sectionsList: RecyclerView? = null
|
||||
private var sectionsAdapter: SectionsAdapter? = null
|
||||
private var viewPager: ViewPager2? = null
|
||||
private var onBackPressedCallback: OnBackPressedCallback? = null
|
||||
|
||||
private var showSections = false
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
viewModel.showSections.value = value
|
||||
val layout = layout
|
||||
layout?.tabs?.let {
|
||||
(0 until it.childCount)
|
||||
.forEach { index -> it.getChildAt(index)!!.isEnabled = !value }
|
||||
}
|
||||
layout?.sectionIcon?.scaleY = if (value) -1f else 1f
|
||||
if (((sectionsList?.parent as? View)?.height ?: 0) > 0) {
|
||||
animateSectionsList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var searchQuery = ""
|
||||
|
||||
private val syncConnection = Connection(
|
||||
serviceClass = SyncService::class.java,
|
||||
onBind = { _, _ ->
|
||||
viewPager?.let {
|
||||
val source = AppListFragment.Source.entries[it.currentItem]
|
||||
updateUpdateNotificationBlocker(source)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
private var sectionsAnimator: ValueAnimator? = null
|
||||
|
||||
private var needSelectUpdates = false
|
||||
|
||||
private val productFragments: Sequence<AppListFragment>
|
||||
get() = if (host == null) {
|
||||
emptySequence()
|
||||
} else {
|
||||
childFragmentManager.fragments.asSequence().mapNotNull { it as? AppListFragment }
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
_tabsBinding = TabsToolbarBinding.inflate(layoutInflater)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
syncConnection.bind(requireContext())
|
||||
|
||||
sectionsAdapter = SectionsAdapter {
|
||||
if (showSections) {
|
||||
viewModel.setSection(it)
|
||||
sectionsList?.scrollToPosition(0)
|
||||
showSections = false
|
||||
}
|
||||
}
|
||||
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
toolbar.title = getString(R.string.application_name)
|
||||
// Move focus from SearchView to Toolbar
|
||||
toolbar.isFocusable = true
|
||||
|
||||
val searchView = FocusSearchView(toolbar.context).apply {
|
||||
maxWidth = Int.MAX_VALUE
|
||||
queryHint = getString(stringRes.search)
|
||||
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
clearFocus()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
if (isResumed) {
|
||||
searchQuery = newText.orEmpty()
|
||||
productFragments.forEach { it.setSearchQuery(newText.orEmpty()) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
toolbar.menu.apply {
|
||||
if (!Huawei.isHuaweiEmui) {
|
||||
sdkAbove(Build.VERSION_CODES.P) {
|
||||
setGroupDividerEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
searchMenuItem = add(0, R.id.toolbar_search, 0, stringRes.search)
|
||||
.setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_search))
|
||||
.setActionView(searchView)
|
||||
.setShowAsActionFlags(
|
||||
MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW
|
||||
)
|
||||
.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
|
||||
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||
viewModel.isSearchActionItemExpanded.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
|
||||
viewModel.isSearchActionItemExpanded.value = false
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
syncRepositoriesMenuItem = add(0, 0, 0, stringRes.sync_repositories)
|
||||
.setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_sync))
|
||||
.setOnMenuItemClickListener {
|
||||
// SyncWorker.startSyncWork(requireContext())
|
||||
syncConnection.binder?.sync(SyncService.SyncRequest.MANUAL)
|
||||
true
|
||||
}
|
||||
|
||||
sortOrderMenu = addSubMenu(0, 0, 0, stringRes.sorting_order)
|
||||
.setIcon(toolbar.context.getMutatedIcon(CommonR.drawable.ic_sort))
|
||||
.let { menu ->
|
||||
menu.item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
val menuItems = SortOrder.entries.map { sortOrder ->
|
||||
menu.add(context.sortOrderName(sortOrder))
|
||||
.setOnMenuItemClickListener {
|
||||
viewModel.setSortOrder(sortOrder)
|
||||
true
|
||||
}
|
||||
}
|
||||
menu.setGroupCheckable(0, true, true)
|
||||
Pair(menu.item, menuItems)
|
||||
}
|
||||
|
||||
favouritesItem = add(1, 0, 0, stringRes.favourites)
|
||||
.setIcon(
|
||||
toolbar.context.getMutatedIcon(CommonR.drawable.ic_favourite_checked)
|
||||
)
|
||||
.setOnMenuItemClickListener {
|
||||
view.post { screenActivity.navigateFavourites() }
|
||||
true
|
||||
}
|
||||
|
||||
add(1, 0, 0, stringRes.repositories)
|
||||
.setOnMenuItemClickListener {
|
||||
view.post { screenActivity.navigateRepositories() }
|
||||
true
|
||||
}
|
||||
|
||||
add(1, 0, 0, stringRes.settings)
|
||||
.setOnMenuItemClickListener {
|
||||
view.post { screenActivity.navigatePreferences() }
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
searchQuery = savedInstanceState?.getString(STATE_SEARCH_QUERY).orEmpty()
|
||||
productFragments.forEach { it.setSearchQuery(searchQuery) }
|
||||
|
||||
val toolbarExtra = fragmentBinding.toolbarExtra
|
||||
toolbarExtra.addView(tabsBinding.root)
|
||||
val layout = Layout(tabsBinding)
|
||||
this.layout = layout
|
||||
|
||||
showSections = (savedInstanceState?.getByte(STATE_SHOW_SECTIONS)?.toInt() ?: 0) != 0
|
||||
|
||||
val content = fragmentBinding.fragmentContent
|
||||
|
||||
viewPager = ViewPager2(content.context).apply {
|
||||
id = R.id.fragment_pager
|
||||
adapter = object : FragmentStateAdapter(this@TabsFragment) {
|
||||
override fun getItemCount(): Int = AppListFragment.Source.entries.size
|
||||
override fun createFragment(position: Int): Fragment = AppListFragment(
|
||||
AppListFragment.Source.entries[position]
|
||||
)
|
||||
}
|
||||
content.addView(this)
|
||||
registerOnPageChangeCallback(pageChangeCallback)
|
||||
offscreenPageLimit = 1
|
||||
}
|
||||
|
||||
viewPager?.let {
|
||||
TabLayoutMediator(layout.tabs, it) { tab, position ->
|
||||
tab.text = getString(AppListFragment.Source.entries[position].titleResId)
|
||||
}.attach()
|
||||
}
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
launch {
|
||||
viewModel.sections.collect(::updateSections)
|
||||
}
|
||||
launch {
|
||||
viewModel.sortOrder.collect(::updateOrder)
|
||||
}
|
||||
launch {
|
||||
viewModel.currentSection.collect(::updateSection)
|
||||
}
|
||||
launch {
|
||||
viewModel.allowHomeScreenSwiping.collect {
|
||||
viewPager?.isUserInputEnabled = it
|
||||
}
|
||||
}
|
||||
launch {
|
||||
viewModel.backAction.collect {
|
||||
onBackPressedCallback?.isEnabled = it != BackAction.None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val backgroundPath = ShapeAppearanceModel.builder()
|
||||
.setAllCornerSizes(
|
||||
context?.resources?.getDimension(CommonR.dimen.shape_large_corner) ?: 0F
|
||||
)
|
||||
.build()
|
||||
val sectionBackground = MaterialShapeDrawable(backgroundPath)
|
||||
val color = SurfaceColors.SURFACE_3.getColor(requireContext())
|
||||
sectionBackground.fillColor = ColorStateList.valueOf(color)
|
||||
val sectionsList = RecyclerView(toolbar.context).apply {
|
||||
id = R.id.sections_list
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
isMotionEventSplittingEnabled = false
|
||||
isVerticalScrollBarEnabled = false
|
||||
setHasFixedSize(true)
|
||||
adapter = sectionsAdapter
|
||||
sectionsAdapter?.let { addDivider(it::configureDivider) }
|
||||
background = sectionBackground
|
||||
elevation = 4.dp.toFloat()
|
||||
content.addView(this)
|
||||
val margins = 8.dp
|
||||
(layoutParams as ViewGroup.MarginLayoutParams).setMargins(margins, margins, margins, 0)
|
||||
visibility = View.GONE
|
||||
systemBarsPadding(includeFab = false)
|
||||
}
|
||||
this.sectionsList = sectionsList
|
||||
|
||||
var lastContentHeight = -1
|
||||
content.viewTreeObserver.addOnGlobalLayoutListener {
|
||||
if (this.view != null) {
|
||||
val initial = lastContentHeight <= 0
|
||||
val contentHeight = content.height
|
||||
if (lastContentHeight != contentHeight) {
|
||||
lastContentHeight = contentHeight
|
||||
if (initial) {
|
||||
sectionsList.layoutParams.height = if (showSections) contentHeight else 0
|
||||
sectionsList.isVisible = showSections
|
||||
sectionsList.requestLayout()
|
||||
} else {
|
||||
animateSectionsList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onBackPressedCallback = object : OnBackPressedCallback(enabled = false) {
|
||||
override fun handleOnBackPressed() {
|
||||
performOnBackPressed()
|
||||
}
|
||||
}
|
||||
onBackPressedCallback?.let {
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
favouritesItem = null
|
||||
searchMenuItem = null
|
||||
sortOrderMenu = null
|
||||
syncRepositoriesMenuItem = null
|
||||
layout = null
|
||||
sectionsList = null
|
||||
sectionsAdapter = null
|
||||
viewPager = null
|
||||
|
||||
syncConnection.unbind(requireContext())
|
||||
sectionsAnimator?.cancel()
|
||||
sectionsAnimator = null
|
||||
|
||||
_tabsBinding = null
|
||||
onBackPressedCallback = null
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
outState.putBoolean(STATE_SEARCH_FOCUSED, searchMenuItem?.actionView?.hasFocus() == true)
|
||||
outState.putString(STATE_SEARCH_QUERY, searchQuery)
|
||||
outState.putByte(STATE_SHOW_SECTIONS, if (showSections) 1 else 0)
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
|
||||
(searchMenuItem?.actionView as FocusSearchView).allowFocus = true
|
||||
if (needSelectUpdates) {
|
||||
needSelectUpdates = false
|
||||
selectUpdatesInternal(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performOnBackPressed() {
|
||||
when (viewModel.backAction.value) {
|
||||
BackAction.ProductAll -> {
|
||||
viewModel.setSection(ProductItem.Section.All)
|
||||
}
|
||||
|
||||
BackAction.CollapseSearchView -> {
|
||||
searchMenuItem?.collapseActionView()
|
||||
}
|
||||
|
||||
BackAction.HideSections -> {
|
||||
showSections = false
|
||||
}
|
||||
|
||||
BackAction.None -> {
|
||||
// should never be called
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun selectUpdates() = selectUpdatesInternal(true)
|
||||
|
||||
private fun updateUpdateNotificationBlocker(activeSource: AppListFragment.Source) {
|
||||
val blockerFragment = if (activeSource == AppListFragment.Source.UPDATES) {
|
||||
productFragments.find { it.source == activeSource }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
syncConnection.binder?.setUpdateNotificationBlocker(blockerFragment)
|
||||
}
|
||||
|
||||
private fun selectUpdatesInternal(allowSmooth: Boolean) {
|
||||
if (view != null) {
|
||||
val viewPager = viewPager
|
||||
viewPager?.setCurrentItem(
|
||||
AppListFragment.Source.UPDATES.ordinal,
|
||||
allowSmooth && viewPager.isLaidOut
|
||||
)
|
||||
} else {
|
||||
needSelectUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateOrder(sortOrder: SortOrder) {
|
||||
sortOrderMenu!!.second[sortOrder.ordinal].isChecked = true
|
||||
}
|
||||
|
||||
private fun updateSections(
|
||||
sectionsList: List<ProductItem.Section>
|
||||
) {
|
||||
sectionsAdapter?.sections = sectionsList
|
||||
layout?.run {
|
||||
sectionIcon.isVisible = sectionsList.any { it !is ProductItem.Section.All }
|
||||
sectionLayout.setOnClickListener { showSections = isVisible && !showSections }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSection(section: ProductItem.Section) {
|
||||
layout?.sectionName?.text = when (section) {
|
||||
is ProductItem.Section.All -> getString(stringRes.all_applications)
|
||||
is ProductItem.Section.Category -> section.name
|
||||
is ProductItem.Section.Repository -> section.name
|
||||
}
|
||||
productFragments.filter { it.source.sections }.forEach { it.setSection(section) }
|
||||
}
|
||||
|
||||
private fun animateSectionsList() {
|
||||
val sectionsList = sectionsList!!
|
||||
val value = if (sectionsList.visibility != View.VISIBLE) {
|
||||
0f
|
||||
} else {
|
||||
sectionsList.height.toFloat() / (sectionsList.parent as View).height
|
||||
}
|
||||
val target = if (showSections) 0.98f else 0f
|
||||
sectionsAnimator?.cancel()
|
||||
sectionsAnimator = null
|
||||
|
||||
if (value != target) {
|
||||
sectionsAnimator = ValueAnimator.ofFloat(value, target).apply {
|
||||
duration = (250 * abs(target - value)).toLong()
|
||||
interpolator = DecelerateInterpolator(2f)
|
||||
addUpdateListener {
|
||||
val newValue = animatedValue as Float
|
||||
sectionsList.apply {
|
||||
val height = ((parent as View).height * newValue).toInt()
|
||||
val visible = height > 0
|
||||
if ((visibility == View.VISIBLE) != visible) isVisible = visible
|
||||
if (layoutParams.height != height) {
|
||||
layoutParams.height = height
|
||||
requestLayout()
|
||||
}
|
||||
}
|
||||
if (target <= 0f && newValue <= 0f) {
|
||||
sectionsAnimator = null
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val pageChangeCallback = object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageScrolled(
|
||||
position: Int,
|
||||
positionOffset: Float,
|
||||
positionOffsetPixels: Int
|
||||
) {
|
||||
val layout = layout!!
|
||||
val fromSections = AppListFragment.Source.entries[position].sections
|
||||
val toSections = if (positionOffset <= 0f) {
|
||||
fromSections
|
||||
} else {
|
||||
AppListFragment.Source.entries[position + 1].sections
|
||||
}
|
||||
val offset = if (fromSections != toSections) {
|
||||
if (fromSections) 1f - positionOffset else positionOffset
|
||||
} else {
|
||||
if (fromSections) 1f else 0f
|
||||
}
|
||||
assert(layout.sectionLayout.childCount == 1)
|
||||
val child = layout.sectionLayout.getChildAt(0)
|
||||
val height = child.layoutParams.height
|
||||
assert(height > 0)
|
||||
val currentHeight = (offset * height).roundToInt()
|
||||
if (layout.sectionLayout.layoutParams.height != currentHeight) {
|
||||
layout.sectionLayout.layoutParams.height = currentHeight
|
||||
layout.sectionLayout.requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageSelected(position: Int) {
|
||||
val source = AppListFragment.Source.entries[position]
|
||||
updateUpdateNotificationBlocker(source)
|
||||
sortOrderMenu!!.first.apply {
|
||||
isVisible = source.order
|
||||
setShowAsActionFlags(
|
||||
if (!source.order ||
|
||||
resources.configuration.screenWidthDp >= 300
|
||||
) {
|
||||
MenuItem.SHOW_AS_ACTION_ALWAYS
|
||||
} else {
|
||||
0
|
||||
}
|
||||
)
|
||||
}
|
||||
syncRepositoriesMenuItem!!.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
if (showSections && !source.sections) {
|
||||
showSections = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageScrollStateChanged(state: Int) {
|
||||
val source = AppListFragment.Source.entries[viewPager!!.currentItem]
|
||||
layout!!.sectionChange.isEnabled =
|
||||
state != ViewPager2.SCROLL_STATE_DRAGGING && source.sections
|
||||
if (state == ViewPager2.SCROLL_STATE_IDLE) {
|
||||
// onPageSelected can be called earlier than fragments created
|
||||
updateUpdateNotificationBlocker(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SectionsAdapter(
|
||||
private val onClick: (ProductItem.Section) -> Unit
|
||||
) : StableRecyclerAdapter<SectionsAdapter.ViewType, RecyclerView.ViewHolder>() {
|
||||
enum class ViewType { SECTION }
|
||||
|
||||
private class SectionViewHolder(context: Context) :
|
||||
RecyclerView.ViewHolder(FrameLayout(context)) {
|
||||
val title: TextView = TextView(context)
|
||||
|
||||
init {
|
||||
with(title) {
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(16.dp, 0, 16.dp, 0)
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
with(itemView as FrameLayout) {
|
||||
layoutParams = RecyclerView.LayoutParams(
|
||||
RecyclerView.LayoutParams.MATCH_PARENT,
|
||||
48.dp
|
||||
)
|
||||
background = context.selectableBackground
|
||||
addView(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sections: List<ProductItem.Section> = emptyList()
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun configureDivider(
|
||||
context: Context,
|
||||
position: Int,
|
||||
configuration: DividerConfiguration
|
||||
) {
|
||||
val currentSection = sections[position]
|
||||
val nextSection = sections.getOrNull(position + 1)
|
||||
when {
|
||||
nextSection != null && currentSection.javaClass != nextSection.javaClass -> {
|
||||
val padding = context.resources.sizeScaled(16)
|
||||
configuration.set(
|
||||
needDivider = true,
|
||||
toTop = false,
|
||||
paddingStart = padding,
|
||||
paddingEnd = padding
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
configuration.set(
|
||||
needDivider = false,
|
||||
toTop = false,
|
||||
paddingStart = 0,
|
||||
paddingEnd = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val viewTypeClass: Class<ViewType>
|
||||
get() = ViewType::class.java
|
||||
|
||||
override fun getItemCount(): Int = sections.size
|
||||
override fun getItemDescriptor(position: Int): String = sections[position].toString()
|
||||
override fun getItemEnumViewType(position: Int): ViewType = ViewType.SECTION
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: ViewType
|
||||
): RecyclerView.ViewHolder {
|
||||
return SectionViewHolder(parent.context).apply {
|
||||
itemView.setOnClickListener { onClick(sections[absoluteAdapterPosition]) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
holder as SectionViewHolder
|
||||
val section = sections[position]
|
||||
val previousSection = sections.getOrNull(position - 1)
|
||||
val nextSection = sections.getOrNull(position + 1)
|
||||
val margin = holder.itemView.resources.sizeScaled(8)
|
||||
val layoutParams = holder.itemView.layoutParams as RecyclerView.LayoutParams
|
||||
layoutParams.topMargin = if (previousSection == null ||
|
||||
section.javaClass != previousSection.javaClass
|
||||
) {
|
||||
margin
|
||||
} else {
|
||||
0
|
||||
}
|
||||
layoutParams.bottomMargin = if (nextSection == null ||
|
||||
section.javaClass != nextSection.javaClass
|
||||
) {
|
||||
margin
|
||||
} else {
|
||||
0
|
||||
}
|
||||
holder.title.text = when (section) {
|
||||
is ProductItem.Section.All -> holder.itemView.resources.getString(
|
||||
stringRes.all_applications
|
||||
)
|
||||
|
||||
is ProductItem.Section.Category -> section.name
|
||||
is ProductItem.Section.Repository -> section.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.looker.droidify.ui.tabsFragment
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.looker.core.common.extension.asStateFlow
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import com.looker.core.datastore.get
|
||||
import com.looker.core.datastore.model.SortOrder
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.ui.tabsFragment.TabsFragment.BackAction
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class TabsViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
val currentSection =
|
||||
savedStateHandle.getStateFlow<ProductItem.Section>(STATE_SECTION, ProductItem.Section.All)
|
||||
|
||||
val sortOrder = settingsRepository
|
||||
.get { sortOrder }
|
||||
.asStateFlow(SortOrder.UPDATED)
|
||||
|
||||
val allowHomeScreenSwiping = settingsRepository
|
||||
.get { homeScreenSwiping }
|
||||
.asStateFlow(false)
|
||||
|
||||
val sections =
|
||||
combine(
|
||||
Database.CategoryAdapter.getAllStream(),
|
||||
Database.RepositoryAdapter.getEnabledStream()
|
||||
) { categories, repos ->
|
||||
val productCategories = categories
|
||||
.asSequence()
|
||||
.sorted()
|
||||
.map(ProductItem.Section::Category)
|
||||
.toList()
|
||||
|
||||
val enabledRepositories = repos
|
||||
.map { ProductItem.Section.Repository(it.id, it.name) }
|
||||
enabledRepositories.ifEmpty { setSection(ProductItem.Section.All) }
|
||||
listOf(ProductItem.Section.All) + productCategories + enabledRepositories
|
||||
}
|
||||
.catch { it.printStackTrace() }
|
||||
.asStateFlow(emptyList())
|
||||
|
||||
val isSearchActionItemExpanded = MutableStateFlow(false)
|
||||
|
||||
val showSections = MutableStateFlow(false)
|
||||
|
||||
val backAction = combine(currentSection, isSearchActionItemExpanded, showSections, ::calcBackAction).asStateFlow(BackAction.None)
|
||||
|
||||
fun setSection(section: ProductItem.Section) {
|
||||
savedStateHandle[STATE_SECTION] = section
|
||||
}
|
||||
|
||||
fun setSortOrder(sortOrder: SortOrder) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setSortOrder(sortOrder)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calcBackAction(
|
||||
currentSection: ProductItem.Section,
|
||||
isSearchActionItemExpanded: Boolean,
|
||||
showSections: Boolean,
|
||||
): BackAction {
|
||||
return when {
|
||||
currentSection != ProductItem.Section.All -> {
|
||||
BackAction.ProductAll
|
||||
}
|
||||
|
||||
isSearchActionItemExpanded -> {
|
||||
BackAction.CollapseSearchView
|
||||
}
|
||||
|
||||
showSections -> {
|
||||
BackAction.HideSections
|
||||
}
|
||||
|
||||
else -> {
|
||||
BackAction.None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STATE_SECTION = "section"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.looker.droidify.utility
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageItemInfo
|
||||
import android.content.pm.PermissionInfo
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import com.looker.core.common.SdkCheck
|
||||
import java.util.Locale
|
||||
|
||||
object PackageItemResolver {
|
||||
class LocalCache {
|
||||
internal val resources = mutableMapOf<String, Resources>()
|
||||
}
|
||||
|
||||
private data class CacheKey(val locales: List<Locale>, val packageName: String, val resId: Int)
|
||||
|
||||
private val cache = mutableMapOf<CacheKey, String?>()
|
||||
|
||||
private fun load(
|
||||
context: Context,
|
||||
localCache: LocalCache,
|
||||
packageName: String,
|
||||
nonLocalized: CharSequence?,
|
||||
resId: Int
|
||||
): CharSequence? {
|
||||
return when {
|
||||
nonLocalized != null -> {
|
||||
nonLocalized
|
||||
}
|
||||
|
||||
resId != 0 -> {
|
||||
val locales = if (SdkCheck.isNougat) {
|
||||
val localesList = context.resources.configuration.locales
|
||||
(0 until localesList.size()).map(localesList::get)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
listOf(context.resources.configuration.locale)
|
||||
}
|
||||
val cacheKey = CacheKey(locales, packageName, resId)
|
||||
if (cache.containsKey(cacheKey)) {
|
||||
cache[cacheKey]
|
||||
} else {
|
||||
val resources = localCache.resources[packageName] ?: run {
|
||||
val resources = try {
|
||||
val resources =
|
||||
context.packageManager.getResourcesForApplication(packageName)
|
||||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(context.resources.configuration, null)
|
||||
resources
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
resources?.let { localCache.resources[packageName] = it }
|
||||
resources
|
||||
}
|
||||
val label = resources?.getString(resId)
|
||||
cache[cacheKey] = label
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLabel(
|
||||
context: Context,
|
||||
localCache: LocalCache,
|
||||
packageItemInfo: PackageItemInfo
|
||||
): CharSequence? {
|
||||
return load(
|
||||
context,
|
||||
localCache,
|
||||
packageItemInfo.packageName,
|
||||
packageItemInfo.nonLocalizedLabel,
|
||||
packageItemInfo.labelRes
|
||||
)
|
||||
}
|
||||
|
||||
fun loadDescription(
|
||||
context: Context,
|
||||
localCache: LocalCache,
|
||||
permissionInfo: PermissionInfo
|
||||
): CharSequence? {
|
||||
return load(
|
||||
context,
|
||||
localCache,
|
||||
permissionInfo.packageName,
|
||||
permissionInfo.nonLocalizedDescription,
|
||||
permissionInfo.descriptionRes
|
||||
)
|
||||
}
|
||||
|
||||
fun getPermissionGroup(permissionInfo: PermissionInfo): String? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
when (permissionInfo.name) {
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.WRITE_CONTACTS,
|
||||
Manifest.permission.GET_ACCOUNTS
|
||||
-> Manifest.permission_group.CONTACTS
|
||||
|
||||
Manifest.permission.READ_CALENDAR,
|
||||
Manifest.permission.WRITE_CALENDAR
|
||||
-> Manifest.permission_group.CALENDAR
|
||||
|
||||
Manifest.permission.SEND_SMS,
|
||||
Manifest.permission.RECEIVE_SMS,
|
||||
Manifest.permission.READ_SMS,
|
||||
Manifest.permission.RECEIVE_MMS,
|
||||
Manifest.permission.RECEIVE_WAP_PUSH,
|
||||
"android.permission.READ_CELL_BROADCASTS"
|
||||
-> Manifest.permission_group.SMS
|
||||
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
Manifest.permission.ACCESS_MEDIA_LOCATION
|
||||
-> Manifest.permission_group.STORAGE
|
||||
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
||||
-> Manifest.permission_group.LOCATION
|
||||
|
||||
Manifest.permission.READ_CALL_LOG,
|
||||
Manifest.permission.WRITE_CALL_LOG,
|
||||
@Suppress("DEPRECATION")
|
||||
Manifest.permission.PROCESS_OUTGOING_CALLS
|
||||
-> Manifest.permission_group.CALL_LOG
|
||||
|
||||
Manifest.permission.READ_PHONE_STATE,
|
||||
Manifest.permission.READ_PHONE_NUMBERS,
|
||||
Manifest.permission.CALL_PHONE,
|
||||
Manifest.permission.ADD_VOICEMAIL,
|
||||
Manifest.permission.USE_SIP,
|
||||
Manifest.permission.ANSWER_PHONE_CALLS,
|
||||
Manifest.permission.ACCEPT_HANDOVER
|
||||
-> Manifest.permission_group.PHONE
|
||||
|
||||
Manifest.permission.RECORD_AUDIO -> Manifest.permission_group.MICROPHONE
|
||||
Manifest.permission.ACTIVITY_RECOGNITION ->
|
||||
Manifest.permission_group.ACTIVITY_RECOGNITION
|
||||
Manifest.permission.CAMERA -> Manifest.permission_group.CAMERA
|
||||
Manifest.permission.BODY_SENSORS -> Manifest.permission_group.SENSORS
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
permissionInfo.group
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.looker.droidify.utility
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
fun InputStream.getProgress(callback: (Long) -> Unit): InputStream =
|
||||
ProgressInputStream(this, callback)
|
||||
|
||||
private class ProgressInputStream(
|
||||
private val inputStream: InputStream,
|
||||
private val callback: (Long) -> Unit
|
||||
) : InputStream() {
|
||||
private var count = 0L
|
||||
|
||||
private inline fun <reified T : Number> notify(one: Boolean, read: () -> T): T {
|
||||
val result = read()
|
||||
count += if (one) 1L else result.toLong()
|
||||
callback(count)
|
||||
return result
|
||||
}
|
||||
|
||||
override fun read(): Int = notify(true) { inputStream.read() }
|
||||
override fun read(b: ByteArray): Int = notify(false) { inputStream.read(b) }
|
||||
override fun read(b: ByteArray, off: Int, len: Int): Int =
|
||||
notify(false) { inputStream.read(b, off, len) }
|
||||
|
||||
override fun skip(n: Long): Long = notify(false) { inputStream.skip(n) }
|
||||
|
||||
override fun available(): Int {
|
||||
return inputStream.available()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
inputStream.close()
|
||||
super.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@file:Suppress("PackageDirectoryMismatch")
|
||||
|
||||
package com.looker.droidify.utility.extension.android
|
||||
|
||||
import android.os.Build
|
||||
|
||||
object Android {
|
||||
val name: String = "Android ${Build.VERSION.RELEASE}"
|
||||
|
||||
val platforms = Build.SUPPORTED_ABIS.toSet()
|
||||
|
||||
val primaryPlatform: String? = Build.SUPPORTED_64_BIT_ABIS?.firstOrNull()
|
||||
?: Build.SUPPORTED_32_BIT_ABIS?.firstOrNull()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.looker.droidify.utility.extension
|
||||
|
||||
import com.looker.droidify.model.InstalledItem
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Repository
|
||||
import com.looker.droidify.model.findSuggested
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.DownloadService
|
||||
import com.looker.droidify.utility.extension.android.Android
|
||||
|
||||
fun Connection<DownloadService.Binder, DownloadService>.startUpdate(
|
||||
packageName: String,
|
||||
installedItem: InstalledItem?,
|
||||
products: List<Pair<Product, Repository>>
|
||||
) {
|
||||
if (binder == null || products.isEmpty()) return
|
||||
|
||||
val (product, repository) = products.findSuggested(installedItem) ?: return
|
||||
|
||||
val compatibleReleases = product.selectedReleases
|
||||
.filter { installedItem == null || installedItem.signature == it.signature }
|
||||
.ifEmpty { return }
|
||||
|
||||
val selectedRelease = compatibleReleases.singleOrNull() ?: compatibleReleases.run {
|
||||
filter { Android.primaryPlatform in it.platforms }.minByOrNull { it.platforms.size }
|
||||
?: minByOrNull { it.platforms.size }
|
||||
?: firstOrNull()
|
||||
} ?: return
|
||||
|
||||
requireNotNull(binder).enqueue(
|
||||
packageName = packageName,
|
||||
name = product.name,
|
||||
repository = repository,
|
||||
release = selectedRelease,
|
||||
isUpdate = installedItem != null
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.looker.droidify.utility.extension
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.looker.droidify.ScreenActivity
|
||||
|
||||
inline val Fragment.screenActivity: ScreenActivity
|
||||
get() = requireActivity() as ScreenActivity
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.looker.droidify.utility.extension
|
||||
|
||||
import android.view.View
|
||||
import com.looker.core.common.Singleton
|
||||
import com.looker.core.common.extension.dpi
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.ProductItem
|
||||
import com.looker.droidify.model.Repository
|
||||
|
||||
object ImageUtils {
|
||||
private val SUPPORTED_DPI = listOf(120, 160, 240, 320, 480, 640)
|
||||
private var DeviceDpi = Singleton<String>()
|
||||
|
||||
fun Product.Screenshot.url(
|
||||
repository: Repository,
|
||||
packageName: String
|
||||
): String {
|
||||
val phoneType = when (type) {
|
||||
Product.Screenshot.Type.PHONE -> "phoneScreenshots"
|
||||
Product.Screenshot.Type.SMALL_TABLET -> "sevenInchScreenshots"
|
||||
Product.Screenshot.Type.LARGE_TABLET -> "tenInchScreenshots"
|
||||
}
|
||||
return "${repository.address}/$packageName/$locale/$phoneType/$path"
|
||||
}
|
||||
|
||||
fun ProductItem.icon(
|
||||
view: View,
|
||||
repository: Repository
|
||||
): String? {
|
||||
if (packageName.isBlank()) return null
|
||||
if (icon.isBlank() && metadataIcon.isBlank()) return null
|
||||
if (repository.version < 11 && icon.isNotBlank()) {
|
||||
return "${repository.address}/icons/$icon"
|
||||
}
|
||||
if (icon.isNotBlank()) {
|
||||
val deviceDpi = DeviceDpi.getOrUpdate {
|
||||
(SUPPORTED_DPI.find { it >= view.dpi } ?: SUPPORTED_DPI.last()).toString()
|
||||
}
|
||||
return "${repository.address}/icons-$deviceDpi/$icon"
|
||||
}
|
||||
if (metadataIcon.isNotBlank()) {
|
||||
return "${repository.address}/$packageName/$metadataIcon"
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.looker.droidify.utility.extension
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
import com.looker.core.common.extension.calculateHash
|
||||
import com.looker.core.common.extension.singleSignature
|
||||
import com.looker.core.common.extension.versionCodeCompat
|
||||
import com.looker.droidify.model.InstalledItem
|
||||
|
||||
fun PackageInfo.toInstalledItem(): InstalledItem {
|
||||
val signatureString = singleSignature?.calculateHash().orEmpty()
|
||||
return InstalledItem(
|
||||
packageName,
|
||||
versionName.orEmpty(),
|
||||
versionCodeCompat,
|
||||
signatureString
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@file:Suppress("PackageDirectoryMismatch")
|
||||
|
||||
package com.looker.droidify.utility.extension.resources
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Typeface
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object TypefaceExtra {
|
||||
val medium = Typeface.create("sans-serif-medium", Typeface.NORMAL)!!
|
||||
val light = Typeface.create("sans-serif-light", Typeface.NORMAL)!!
|
||||
}
|
||||
|
||||
fun Resources.sizeScaled(size: Int): Int {
|
||||
return (size * displayMetrics.density).roundToInt()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.looker.droidify.utility.serialization
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.looker.core.common.extension.forEachKey
|
||||
import com.looker.droidify.model.ProductItem
|
||||
|
||||
fun ProductItem.serialize(generator: JsonGenerator) {
|
||||
generator.writeNumberField("serialVersion", 1)
|
||||
generator.writeNumberField("repositoryId", repositoryId)
|
||||
generator.writeStringField("packageName", packageName)
|
||||
generator.writeStringField("name", name)
|
||||
generator.writeStringField("summary", summary)
|
||||
generator.writeStringField("icon", icon)
|
||||
generator.writeStringField("metadataIcon", metadataIcon)
|
||||
generator.writeStringField("version", version)
|
||||
generator.writeStringField("installedVersion", installedVersion)
|
||||
generator.writeBooleanField("compatible", compatible)
|
||||
generator.writeBooleanField("canUpdate", canUpdate)
|
||||
generator.writeNumberField("matchRank", matchRank)
|
||||
}
|
||||
|
||||
fun JsonParser.productItem(): ProductItem {
|
||||
var repositoryId = 0L
|
||||
var packageName = ""
|
||||
var name = ""
|
||||
var summary = ""
|
||||
var icon = ""
|
||||
var metadataIcon = ""
|
||||
var version = ""
|
||||
var installedVersion = ""
|
||||
var compatible = false
|
||||
var canUpdate = false
|
||||
var matchRank = 0
|
||||
forEachKey {
|
||||
when {
|
||||
it.number("repositoryId") -> repositoryId = valueAsLong
|
||||
it.string("packageName") -> packageName = valueAsString
|
||||
it.string("name") -> name = valueAsString
|
||||
it.string("summary") -> summary = valueAsString
|
||||
it.string("icon") -> icon = valueAsString
|
||||
it.string("metadataIcon") -> metadataIcon = valueAsString
|
||||
it.string("version") -> version = valueAsString
|
||||
it.string("installedVersion") -> installedVersion = valueAsString
|
||||
it.boolean("compatible") -> compatible = valueAsBoolean
|
||||
it.boolean("canUpdate") -> canUpdate = valueAsBoolean
|
||||
it.number("matchRank") -> matchRank = valueAsInt
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
return ProductItem(
|
||||
repositoryId, packageName, name, summary, icon, metadataIcon,
|
||||
version, installedVersion, compatible, canUpdate, matchRank
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.looker.droidify.utility.serialization
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.looker.core.common.extension.forEachKey
|
||||
import com.looker.droidify.model.ProductPreference
|
||||
|
||||
fun ProductPreference.serialize(generator: JsonGenerator) {
|
||||
generator.writeBooleanField("ignoreUpdates", ignoreUpdates)
|
||||
generator.writeNumberField("ignoreVersionCode", ignoreVersionCode)
|
||||
}
|
||||
|
||||
fun JsonParser.productPreference(): ProductPreference {
|
||||
var ignoreUpdates = false
|
||||
var ignoreVersionCode = 0L
|
||||
forEachKey {
|
||||
when {
|
||||
it.boolean("ignoreUpdates") -> ignoreUpdates = valueAsBoolean
|
||||
it.number("ignoreVersionCode") -> ignoreVersionCode = valueAsLong
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
return ProductPreference(ignoreUpdates, ignoreVersionCode)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.looker.droidify.utility.serialization
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.looker.core.common.extension.collectNotNull
|
||||
import com.looker.core.common.extension.collectNotNullStrings
|
||||
import com.looker.core.common.extension.forEachKey
|
||||
import com.looker.core.common.extension.writeArray
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.droidify.model.Product
|
||||
import com.looker.droidify.model.Release
|
||||
|
||||
fun Product.serialize(generator: JsonGenerator) {
|
||||
generator.writeNumberField("repositoryId", repositoryId)
|
||||
generator.writeNumberField("serialVersion", 1)
|
||||
generator.writeStringField("packageName", packageName)
|
||||
generator.writeStringField("name", name)
|
||||
generator.writeStringField("summary", summary)
|
||||
generator.writeStringField("description", description)
|
||||
generator.writeStringField("whatsNew", whatsNew)
|
||||
generator.writeStringField("icon", icon)
|
||||
generator.writeStringField("metadataIcon", metadataIcon)
|
||||
generator.writeStringField("authorName", author.name)
|
||||
generator.writeStringField("authorEmail", author.email)
|
||||
generator.writeStringField("authorWeb", author.web)
|
||||
generator.writeStringField("source", source)
|
||||
generator.writeStringField("changelog", changelog)
|
||||
generator.writeStringField("web", web)
|
||||
generator.writeStringField("tracker", tracker)
|
||||
generator.writeNumberField("added", added)
|
||||
generator.writeNumberField("updated", updated)
|
||||
generator.writeNumberField("suggestedVersionCode", suggestedVersionCode)
|
||||
generator.writeArray("categories") { categories.forEach(::writeString) }
|
||||
generator.writeArray("antiFeatures") { antiFeatures.forEach(::writeString) }
|
||||
generator.writeArray("licenses") { licenses.forEach(::writeString) }
|
||||
generator.writeArray("donates") {
|
||||
donates.forEach {
|
||||
writeDictionary {
|
||||
when (it) {
|
||||
is Product.Donate.Regular -> {
|
||||
writeStringField("type", "")
|
||||
writeStringField("url", it.url)
|
||||
}
|
||||
|
||||
is Product.Donate.Bitcoin -> {
|
||||
writeStringField("type", "bitcoin")
|
||||
writeStringField("address", it.address)
|
||||
}
|
||||
|
||||
is Product.Donate.Litecoin -> {
|
||||
writeStringField("type", "litecoin")
|
||||
writeStringField("address", it.address)
|
||||
}
|
||||
|
||||
is Product.Donate.Flattr -> {
|
||||
writeStringField("type", "flattr")
|
||||
writeStringField("id", it.id)
|
||||
}
|
||||
|
||||
is Product.Donate.Liberapay -> {
|
||||
writeStringField("type", "liberapay")
|
||||
writeStringField("id", it.id)
|
||||
}
|
||||
|
||||
is Product.Donate.OpenCollective -> {
|
||||
writeStringField("type", "openCollective")
|
||||
writeStringField("id", it.id)
|
||||
}
|
||||
}::class
|
||||
}
|
||||
}
|
||||
}
|
||||
generator.writeArray("screenshots") {
|
||||
screenshots.forEach {
|
||||
writeDictionary {
|
||||
writeStringField("locale", it.locale)
|
||||
writeStringField("type", it.type.jsonName)
|
||||
writeStringField("path", it.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
generator.writeArray("releases") { releases.forEach { writeDictionary { it.serialize(this) } } }
|
||||
}
|
||||
|
||||
fun JsonParser.product(): Product {
|
||||
var repositoryId = 0L
|
||||
var packageName = ""
|
||||
var name = ""
|
||||
var summary = ""
|
||||
var description = ""
|
||||
var whatsNew = ""
|
||||
var icon = ""
|
||||
var metadataIcon = ""
|
||||
var authorName = ""
|
||||
var authorEmail = ""
|
||||
var authorWeb = ""
|
||||
var source = ""
|
||||
var changelog = ""
|
||||
var web = ""
|
||||
var tracker = ""
|
||||
var added = 0L
|
||||
var updated = 0L
|
||||
var suggestedVersionCode = 0L
|
||||
var categories = emptyList<String>()
|
||||
var antiFeatures = emptyList<String>()
|
||||
var licenses = emptyList<String>()
|
||||
var donates = emptyList<Product.Donate>()
|
||||
var screenshots = emptyList<Product.Screenshot>()
|
||||
var releases = emptyList<Release>()
|
||||
forEachKey { it ->
|
||||
when {
|
||||
it.string("repositoryId") -> repositoryId = valueAsLong
|
||||
it.string("packageName") -> packageName = valueAsString
|
||||
it.string("name") -> name = valueAsString
|
||||
it.string("summary") -> summary = valueAsString
|
||||
it.string("description") -> description = valueAsString
|
||||
it.string("whatsNew") -> whatsNew = valueAsString
|
||||
it.string("icon") -> icon = valueAsString
|
||||
it.string("metadataIcon") -> metadataIcon = valueAsString
|
||||
it.string("authorName") -> authorName = valueAsString
|
||||
it.string("authorEmail") -> authorEmail = valueAsString
|
||||
it.string("authorWeb") -> authorWeb = valueAsString
|
||||
it.string("source") -> source = valueAsString
|
||||
it.string("changelog") -> changelog = valueAsString
|
||||
it.string("web") -> web = valueAsString
|
||||
it.string("tracker") -> tracker = valueAsString
|
||||
it.number("added") -> added = valueAsLong
|
||||
it.number("updated") -> updated = valueAsLong
|
||||
it.number("suggestedVersionCode") -> suggestedVersionCode = valueAsLong
|
||||
it.array("categories") -> categories = collectNotNullStrings()
|
||||
it.array("antiFeatures") -> antiFeatures = collectNotNullStrings()
|
||||
it.array("licenses") -> licenses = collectNotNullStrings()
|
||||
it.array("donates") -> donates = collectNotNull(JsonToken.START_OBJECT) {
|
||||
var type = ""
|
||||
var url = ""
|
||||
var address = ""
|
||||
var id = ""
|
||||
forEachKey {
|
||||
when {
|
||||
it.string("type") -> type = valueAsString
|
||||
it.string("url") -> url = valueAsString
|
||||
it.string("address") -> address = valueAsString
|
||||
it.string("id") -> id = valueAsString
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
when (type) {
|
||||
"" -> Product.Donate.Regular(url)
|
||||
"bitcoin" -> Product.Donate.Bitcoin(address)
|
||||
"litecoin" -> Product.Donate.Litecoin(address)
|
||||
"flattr" -> Product.Donate.Flattr(id)
|
||||
"liberapay" -> Product.Donate.Liberapay(id)
|
||||
"openCollective" -> Product.Donate.OpenCollective(id)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
it.array("screenshots") ->
|
||||
screenshots =
|
||||
collectNotNull(JsonToken.START_OBJECT) {
|
||||
var locale = ""
|
||||
var type = ""
|
||||
var path = ""
|
||||
forEachKey {
|
||||
when {
|
||||
it.string("locale") -> locale = valueAsString
|
||||
it.string("type") -> type = valueAsString
|
||||
it.string("path") -> path = valueAsString
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
Product.Screenshot.Type.entries.find { it.jsonName == type }
|
||||
?.let { Product.Screenshot(locale, it, path) }
|
||||
}
|
||||
|
||||
it.array("releases") ->
|
||||
releases =
|
||||
collectNotNull(JsonToken.START_OBJECT) { release() }
|
||||
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
return Product(
|
||||
repositoryId,
|
||||
packageName,
|
||||
name,
|
||||
summary,
|
||||
description,
|
||||
whatsNew,
|
||||
icon,
|
||||
metadataIcon,
|
||||
Product.Author(authorName, authorEmail, authorWeb),
|
||||
source,
|
||||
changelog,
|
||||
web,
|
||||
tracker,
|
||||
added,
|
||||
updated,
|
||||
suggestedVersionCode,
|
||||
categories,
|
||||
antiFeatures,
|
||||
licenses,
|
||||
donates,
|
||||
screenshots,
|
||||
releases
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.looker.droidify.utility.serialization
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.looker.core.common.extension.collectNotNull
|
||||
import com.looker.core.common.extension.collectNotNullStrings
|
||||
import com.looker.core.common.extension.forEachKey
|
||||
import com.looker.core.common.extension.writeArray
|
||||
import com.looker.core.common.extension.writeDictionary
|
||||
import com.looker.droidify.model.Release
|
||||
|
||||
fun Release.serialize(generator: JsonGenerator) {
|
||||
generator.writeNumberField("serialVersion", 1)
|
||||
generator.writeBooleanField("selected", selected)
|
||||
generator.writeStringField("version", version)
|
||||
generator.writeNumberField("versionCode", versionCode)
|
||||
generator.writeNumberField("added", added)
|
||||
generator.writeNumberField("size", size)
|
||||
generator.writeNumberField("minSdkVersion", minSdkVersion)
|
||||
generator.writeNumberField("targetSdkVersion", targetSdkVersion)
|
||||
generator.writeNumberField("maxSdkVersion", maxSdkVersion)
|
||||
generator.writeStringField("source", source)
|
||||
generator.writeStringField("release", release)
|
||||
generator.writeStringField("hash", hash)
|
||||
generator.writeStringField("hashType", hashType)
|
||||
generator.writeStringField("signature", signature)
|
||||
generator.writeStringField("obbMain", obbMain)
|
||||
generator.writeStringField("obbMainHash", obbMainHash)
|
||||
generator.writeStringField("obbMainHashType", obbMainHashType)
|
||||
generator.writeStringField("obbPatch", obbPatch)
|
||||
generator.writeStringField("obbPatchHash", obbPatchHash)
|
||||
generator.writeStringField("obbPatchHashType", obbPatchHashType)
|
||||
generator.writeArray("permissions") { permissions.forEach { writeString(it) } }
|
||||
generator.writeArray("features") { features.forEach { writeString(it) } }
|
||||
generator.writeArray("platforms") { platforms.forEach { writeString(it) } }
|
||||
generator.writeArray("incompatibilities") {
|
||||
incompatibilities.forEach {
|
||||
writeDictionary {
|
||||
when (it) {
|
||||
is Release.Incompatibility.MinSdk -> {
|
||||
writeStringField("type", "minSdk")
|
||||
}
|
||||
|
||||
is Release.Incompatibility.MaxSdk -> {
|
||||
writeStringField("type", "maxSdk")
|
||||
}
|
||||
|
||||
is Release.Incompatibility.Platform -> {
|
||||
writeStringField("type", "platform")
|
||||
}
|
||||
|
||||
is Release.Incompatibility.Feature -> {
|
||||
writeStringField("type", "feature")
|
||||
writeStringField("feature", it.feature)
|
||||
}
|
||||
}::class
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun JsonParser.release(): Release {
|
||||
var selected = false
|
||||
var version = ""
|
||||
var versionCode = 0L
|
||||
var added = 0L
|
||||
var size = 0L
|
||||
var minSdkVersion = 0
|
||||
var targetSdkVersion = 0
|
||||
var maxSdkVersion = 0
|
||||
var source = ""
|
||||
var release = ""
|
||||
var hash = ""
|
||||
var hashType = ""
|
||||
var signature = ""
|
||||
var obbMain = ""
|
||||
var obbMainHash = ""
|
||||
var obbMainHashType = ""
|
||||
var obbPatch = ""
|
||||
var obbPatchHash = ""
|
||||
var obbPatchHashType = ""
|
||||
var permissions = emptyList<String>()
|
||||
var features = emptyList<String>()
|
||||
var platforms = emptyList<String>()
|
||||
var incompatibilities = emptyList<Release.Incompatibility>()
|
||||
forEachKey { it ->
|
||||
when {
|
||||
it.boolean("selected") -> selected = valueAsBoolean
|
||||
it.string("version") -> version = valueAsString
|
||||
it.number("versionCode") -> versionCode = valueAsLong
|
||||
it.number("added") -> added = valueAsLong
|
||||
it.number("size") -> size = valueAsLong
|
||||
it.number("minSdkVersion") -> minSdkVersion = valueAsInt
|
||||
it.number("targetSdkVersion") -> targetSdkVersion = valueAsInt
|
||||
it.number("maxSdkVersion") -> maxSdkVersion = valueAsInt
|
||||
it.string("source") -> source = valueAsString
|
||||
it.string("release") -> release = valueAsString
|
||||
it.string("hash") -> hash = valueAsString
|
||||
it.string("hashType") -> hashType = valueAsString
|
||||
it.string("signature") -> signature = valueAsString
|
||||
it.string("obbMain") -> obbMain = valueAsString
|
||||
it.string("obbMainHash") -> obbMainHash = valueAsString
|
||||
it.string("obbMainHashType") -> obbMainHashType = valueAsString
|
||||
it.string("obbPatch") -> obbPatch = valueAsString
|
||||
it.string("obbPatchHash") -> obbPatchHash = valueAsString
|
||||
it.string("obbPatchHashType") -> obbPatchHashType = valueAsString
|
||||
it.array("permissions") -> permissions = collectNotNullStrings()
|
||||
it.array("features") -> features = collectNotNullStrings()
|
||||
it.array("platforms") -> platforms = collectNotNullStrings()
|
||||
it.array("incompatibilities") ->
|
||||
incompatibilities =
|
||||
collectNotNull(JsonToken.START_OBJECT) {
|
||||
var type = ""
|
||||
var feature = ""
|
||||
forEachKey {
|
||||
when {
|
||||
it.string("type") -> type = valueAsString
|
||||
it.string("feature") -> feature = valueAsString
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
when (type) {
|
||||
"minSdk" -> Release.Incompatibility.MinSdk
|
||||
"maxSdk" -> Release.Incompatibility.MaxSdk
|
||||
"platform" -> Release.Incompatibility.Platform
|
||||
"feature" -> Release.Incompatibility.Feature(feature)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
return Release(
|
||||
selected,
|
||||
version,
|
||||
versionCode,
|
||||
added,
|
||||
size,
|
||||
minSdkVersion,
|
||||
targetSdkVersion,
|
||||
maxSdkVersion,
|
||||
source,
|
||||
release,
|
||||
hash,
|
||||
hashType,
|
||||
signature,
|
||||
obbMain,
|
||||
obbMainHash,
|
||||
obbMainHashType,
|
||||
obbPatch,
|
||||
obbPatchHash,
|
||||
obbPatchHashType,
|
||||
permissions,
|
||||
features,
|
||||
platforms,
|
||||
incompatibilities
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.looker.droidify.utility.serialization
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.looker.core.common.extension.collectNotNullStrings
|
||||
import com.looker.core.common.extension.forEachKey
|
||||
import com.looker.core.common.extension.writeArray
|
||||
import com.looker.droidify.model.Repository
|
||||
|
||||
fun Repository.serialize(generator: JsonGenerator) {
|
||||
generator.writeNumberField("serialVersion", 1)
|
||||
generator.writeNumberField("id", id)
|
||||
generator.writeStringField("address", address)
|
||||
generator.writeArray("mirrors") { mirrors.forEach { writeString(it) } }
|
||||
generator.writeStringField("name", name)
|
||||
generator.writeStringField("description", description)
|
||||
generator.writeNumberField("version", version)
|
||||
generator.writeBooleanField("enabled", enabled)
|
||||
generator.writeStringField("fingerprint", fingerprint)
|
||||
generator.writeStringField("lastModified", lastModified)
|
||||
generator.writeStringField("entityTag", entityTag)
|
||||
generator.writeNumberField("updated", updated)
|
||||
generator.writeNumberField("timestamp", timestamp)
|
||||
generator.writeStringField("authentication", authentication)
|
||||
}
|
||||
|
||||
fun JsonParser.repository(): Repository {
|
||||
var id = -1L
|
||||
var address = ""
|
||||
var mirrors = emptyList<String>()
|
||||
var name = ""
|
||||
var description = ""
|
||||
var version = 0
|
||||
var enabled = false
|
||||
var fingerprint = ""
|
||||
var lastModified = ""
|
||||
var entityTag = ""
|
||||
var updated = 0L
|
||||
var timestamp = 0L
|
||||
var authentication = ""
|
||||
forEachKey {
|
||||
when {
|
||||
it.string("id") -> id = valueAsLong
|
||||
it.string("address") -> address = valueAsString
|
||||
it.array("mirrors") -> mirrors = collectNotNullStrings()
|
||||
it.string("name") -> name = valueAsString
|
||||
it.string("description") -> description = valueAsString
|
||||
it.number("version") -> version = valueAsInt
|
||||
it.boolean("enabled") -> enabled = valueAsBoolean
|
||||
it.string("fingerprint") -> fingerprint = valueAsString
|
||||
it.string("lastModified") -> lastModified = valueAsString
|
||||
it.string("entityTag") -> entityTag = valueAsString
|
||||
it.number("updated") -> updated = valueAsLong
|
||||
it.number("timestamp") -> timestamp = valueAsLong
|
||||
it.string("authentication") -> authentication = valueAsString
|
||||
else -> skipChildren()
|
||||
}
|
||||
}
|
||||
return Repository(
|
||||
id, address, mirrors, name, description, version, enabled, fingerprint,
|
||||
lastModified, entityTag, updated, timestamp, authentication
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.looker.droidify.widget
|
||||
|
||||
import android.database.Cursor
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
abstract class CursorRecyclerAdapter<VT : Enum<VT>, VH : RecyclerView.ViewHolder> :
|
||||
EnumRecyclerAdapter<VT, VH>() {
|
||||
init {
|
||||
super.setHasStableIds(true)
|
||||
}
|
||||
|
||||
private var rowIdIndex = 0
|
||||
|
||||
var cursor: Cursor? = null
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field?.close()
|
||||
field = value
|
||||
rowIdIndex = value?.getColumnIndexOrThrow("_id") ?: 0
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
final override fun setHasStableIds(hasStableIds: Boolean) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = cursor?.count ?: 0
|
||||
override fun getItemId(position: Int): Long = moveTo(position).getLong(rowIdIndex)
|
||||
|
||||
fun moveTo(position: Int): Cursor {
|
||||
val cursor = cursor!!
|
||||
cursor.moveToPosition(position)
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.looker.droidify.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.looker.core.common.extension.divider
|
||||
import com.looker.droidify.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun RecyclerView.addDivider(
|
||||
configure: (
|
||||
context: Context,
|
||||
position: Int,
|
||||
configuration: DividerConfiguration
|
||||
) -> Unit
|
||||
) {
|
||||
addItemDecoration(
|
||||
DividerItemDecoration(
|
||||
context = context,
|
||||
configure = configure
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun interface DividerConfiguration {
|
||||
fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int)
|
||||
}
|
||||
|
||||
private class DividerItemDecoration(
|
||||
context: Context,
|
||||
private val configure: (
|
||||
context: Context,
|
||||
position: Int,
|
||||
configuration: DividerConfiguration
|
||||
) -> Unit
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private class ConfigurationHolder : DividerConfiguration {
|
||||
var needDivider = false
|
||||
var toTop = false
|
||||
var paddingStart = 0
|
||||
var paddingEnd = 0
|
||||
|
||||
override fun set(needDivider: Boolean, toTop: Boolean, paddingStart: Int, paddingEnd: Int) {
|
||||
this.needDivider = needDivider
|
||||
this.toTop = toTop
|
||||
this.paddingStart = paddingStart
|
||||
this.paddingEnd = paddingEnd
|
||||
}
|
||||
}
|
||||
|
||||
private val View.configuration: ConfigurationHolder
|
||||
get() = getTag(R.id.divider_configuration) as? ConfigurationHolder ?: run {
|
||||
val configuration = ConfigurationHolder()
|
||||
setTag(R.id.divider_configuration, configuration)
|
||||
configuration
|
||||
}
|
||||
|
||||
private val divider = context.divider
|
||||
private val bounds = Rect()
|
||||
|
||||
private fun draw(
|
||||
c: Canvas,
|
||||
configuration: ConfigurationHolder,
|
||||
view: View,
|
||||
top: Int,
|
||||
width: Int,
|
||||
rtl: Boolean
|
||||
) {
|
||||
val divider = divider
|
||||
val left = if (rtl) configuration.paddingEnd else configuration.paddingStart
|
||||
val right = width - (if (rtl) configuration.paddingStart else configuration.paddingEnd)
|
||||
val translatedTop = top + view.translationY.roundToInt()
|
||||
divider.alpha = (view.alpha * 0xff).toInt()
|
||||
divider.setBounds(left, translatedTop, right, translatedTop + divider.intrinsicHeight)
|
||||
divider.draw(c)
|
||||
}
|
||||
|
||||
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
val divider = divider
|
||||
val bounds = bounds
|
||||
val rtl = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL
|
||||
for (i in 0 until parent.childCount) {
|
||||
val view = parent.getChildAt(i)
|
||||
val configuration = view.configuration
|
||||
if (configuration.needDivider) {
|
||||
val position = parent.getChildAdapterPosition(view)
|
||||
if (position == parent.adapter!!.itemCount - 1) {
|
||||
parent.getDecoratedBoundsWithMargins(view, bounds)
|
||||
draw(c, configuration, view, bounds.bottom, parent.width, rtl)
|
||||
} else {
|
||||
val toTopView = if (configuration.toTop && position >= 0) {
|
||||
parent.findViewHolderForAdapterPosition(position + 1)?.itemView
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (toTopView != null) {
|
||||
parent.getDecoratedBoundsWithMargins(toTopView, bounds)
|
||||
draw(
|
||||
c,
|
||||
configuration,
|
||||
toTopView,
|
||||
bounds.top - divider.intrinsicHeight,
|
||||
parent.width,
|
||||
rtl
|
||||
)
|
||||
} else {
|
||||
parent.getDecoratedBoundsWithMargins(view, bounds)
|
||||
draw(
|
||||
c,
|
||||
configuration,
|
||||
view,
|
||||
bounds.bottom - divider.intrinsicHeight,
|
||||
parent.width,
|
||||
rtl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
val configuration = view.configuration
|
||||
val position = parent.getChildAdapterPosition(view)
|
||||
if (position >= 0) {
|
||||
configure(view.context, position, configuration)
|
||||
}
|
||||
val needDivider = position < parent.adapter!!.itemCount - 1 && configuration.needDivider
|
||||
outRect.set(0, 0, 0, if (needDivider) divider.intrinsicHeight else 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.looker.droidify.widget
|
||||
|
||||
import android.util.SparseArray
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
abstract class EnumRecyclerAdapter<VT : Enum<VT>, VH : RecyclerView.ViewHolder> :
|
||||
RecyclerView.Adapter<VH>() {
|
||||
abstract val viewTypeClass: Class<VT>
|
||||
|
||||
private val names = SparseArray<String>()
|
||||
|
||||
private fun getViewType(viewType: Int): VT {
|
||||
return java.lang.Enum.valueOf(viewTypeClass, names.get(viewType))
|
||||
}
|
||||
|
||||
final override fun getItemViewType(position: Int): Int {
|
||||
val enum = getItemEnumViewType(position)
|
||||
names.put(enum.ordinal, enum.name)
|
||||
return enum.ordinal
|
||||
}
|
||||
|
||||
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
return onCreateViewHolder(parent, getViewType(viewType))
|
||||
}
|
||||
|
||||
abstract fun getItemEnumViewType(position: Int): VT
|
||||
abstract fun onCreateViewHolder(parent: ViewGroup, viewType: VT): VH
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.looker.droidify.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.KeyEvent
|
||||
import androidx.appcompat.widget.SearchView
|
||||
|
||||
class FocusSearchView : SearchView {
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
|
||||
context,
|
||||
attrs,
|
||||
defStyleAttr
|
||||
)
|
||||
|
||||
var allowFocus = true
|
||||
|
||||
override fun dispatchKeyEventPreIme(event: KeyEvent): Boolean {
|
||||
// Always clear focus on back press
|
||||
return if (hasFocus() && event.keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
if (event.action == KeyEvent.ACTION_UP) {
|
||||
clearFocus()
|
||||
}
|
||||
true
|
||||
} else {
|
||||
super.dispatchKeyEventPreIme(event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setIconified(iconify: Boolean) {
|
||||
super.setIconified(iconify)
|
||||
|
||||
// Don't focus view and raise keyboard unless allowed
|
||||
if (!iconify && !allowFocus) {
|
||||
clearFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.looker.droidify.widget
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
abstract class StableRecyclerAdapter<VT : Enum<VT>, VH : RecyclerView.ViewHolder> :
|
||||
EnumRecyclerAdapter<VT, VH>() {
|
||||
private var nextId = 1L
|
||||
private val descriptorToId = mutableMapOf<String, Long>()
|
||||
|
||||
init {
|
||||
super.setHasStableIds(true)
|
||||
}
|
||||
|
||||
final override fun setHasStableIds(hasStableIds: Boolean) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
val descriptor = getItemDescriptor(position)
|
||||
return descriptorToId[descriptor] ?: run {
|
||||
val id = nextId++
|
||||
descriptorToId[descriptor] = id
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun getItemDescriptor(position: Int): String
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.looker.droidify.work
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.looker.core.common.cache.Cache
|
||||
import com.looker.core.datastore.SettingsRepository
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.toJavaDuration
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@HiltWorker
|
||||
class CleanUpWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted workerParams: WorkerParameters,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "CleanUpWorker"
|
||||
|
||||
fun removeAllSchedules(context: Context) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.cancelUniqueWork(TAG)
|
||||
}
|
||||
|
||||
fun scheduleCleanup(context: Context, duration: Duration) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val cleanup = PeriodicWorkRequestBuilder<CleanUpWorker>(duration.toJavaDuration())
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
TAG,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
cleanup
|
||||
)
|
||||
Log.i(TAG, "Periodic work enqueued with duration: $duration")
|
||||
}
|
||||
|
||||
fun force(context: Context) {
|
||||
val cleanup = OneTimeWorkRequestBuilder<CleanUpWorker>()
|
||||
.build()
|
||||
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.enqueueUniqueWork(
|
||||
"$TAG.force",
|
||||
ExistingWorkPolicy.KEEP,
|
||||
cleanup
|
||||
)
|
||||
Log.i(TAG, "Forced cleanup enqueued")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Log.i(TAG, "doWork: Started Cleanup")
|
||||
settingsRepository.setCleanupInstant()
|
||||
Cache.cleanup(applicationContext)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
Log.i(TAG, "doWork: Failed to clean up", e)
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
}
|
||||
11
app/src/main/res/anim/slide_right_fade_in.xml
Normal file
11
app/src/main/res/anim/slide_right_fade_in.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<translate
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:fromXDelta="-10%p"
|
||||
android:toXDelta="0" />
|
||||
<alpha
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:fromAlpha="0.0"
|
||||
android:toAlpha="1.0" />
|
||||
</set>
|
||||
11
app/src/main/res/anim/slide_right_fade_out.xml
Normal file
11
app/src/main/res/anim/slide_right_fade_out.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<translate
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:fromXDelta="0"
|
||||
android:toXDelta="10%p" />
|
||||
<alpha
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:fromAlpha="1.0"
|
||||
android:toAlpha="0.0" />
|
||||
</set>
|
||||
18
app/src/main/res/animator/slide_in.xml
Normal file
18
app/src/main/res/animator/slide_in.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<objectAnimator
|
||||
android:duration="100"
|
||||
android:interpolator="@android:interpolator/decelerate_quad"
|
||||
android:propertyName="alpha"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="1" />
|
||||
|
||||
<objectAnimator
|
||||
android:duration="175"
|
||||
android:interpolator="@android:interpolator/decelerate_quad"
|
||||
android:propertyName="percentTranslationY"
|
||||
android:valueFrom="0.08"
|
||||
android:valueTo="0" />
|
||||
|
||||
</set>
|
||||
3
app/src/main/res/animator/slide_in_keep.xml
Normal file
3
app/src/main/res/animator/slide_in_keep.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:duration="175" />
|
||||
19
app/src/main/res/animator/slide_out.xml
Normal file
19
app/src/main/res/animator/slide_out.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<objectAnimator
|
||||
android:duration="75"
|
||||
android:interpolator="@android:interpolator/linear"
|
||||
android:propertyName="alpha"
|
||||
android:startOffset="50"
|
||||
android:valueFrom="1"
|
||||
android:valueTo="0" />
|
||||
|
||||
<objectAnimator
|
||||
android:duration="125"
|
||||
android:interpolator="@android:interpolator/accelerate_quad"
|
||||
android:propertyName="percentTranslationY"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="0.08" />
|
||||
|
||||
</set>
|
||||
6
app/src/main/res/drawable/favourite_icon.xml
Normal file
6
app/src/main/res/drawable/favourite_icon.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/ic_favourite" android:state_checked="false" />
|
||||
<item android:drawable="@drawable/ic_favourite_checked" android:state_checked="true" />
|
||||
<item android:drawable="@drawable/ic_favourite" />
|
||||
</selector>
|
||||
53
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
53
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:scaleX="0.7087745"
|
||||
android:scaleY="0.7087745"
|
||||
android:translateX="20.057142"
|
||||
android:translateY="20.37143">
|
||||
<path
|
||||
android:pathData="m28.56,57.594a22.542,22.542 0,0 1,4.775 -28.811,22.542 22.542,0 0,1 29.204,0.079 22.542,22.542 0,0 1,4.618 28.837"
|
||||
android:strokeWidth="6.61447"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#7ada9d"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m47.89,51.594c-1.387,0 -2.503,1.384 -2.503,3.102v11.899l-4.849,-4.849c-0.909,-0.909 -2.373,-0.909 -3.282,0l-0.257,0.257c-0.909,0.909 -0.909,2.373 0,3.282l9.173,9.173c0.466,0.466 1.078,0.692 1.686,0.681 0.011,0 0.022,0.002 0.033,0.002 0.011,0 0.022,-0.001 0.033,-0.002 0.608,0.012 1.22,-0.215 1.686,-0.681l9.173,-9.173c0.909,-0.909 0.909,-2.373 0,-3.282l-0.257,-0.257c-0.909,-0.909 -2.373,-0.909 -3.282,0l-4.849,4.849v-11.899c0,-1.719 -1.116,-3.102 -2.503,-3.102z"
|
||||
android:strokeWidth="5.40899"
|
||||
android:fillColor="#ffb780"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M41.407,40.614m-2.249,0a2.249,2.249 0,1 1,4.498 0a2.249,2.249 0,1 1,-4.498 0"
|
||||
android:strokeWidth="5.42057"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M55.166,40.614m-2.249,0a2.249,2.249 0,1 1,4.498 0a2.249,2.249 0,1 1,-4.498 0"
|
||||
android:strokeWidth="5.42057"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M69.466,23.098L69.903,23.535A1.411,1.411 90,0 1,69.903 25.531L65.35,30.083A1.411,1.411 0,0 1,63.355 30.083L62.918,29.647A1.411,1.411 0,0 1,62.918 27.651L67.47,23.098A1.411,1.411 0,0 1,69.466 23.098z"
|
||||
android:strokeWidth="8.92799"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M25.784,23.098L25.347,23.535A1.411,1.411 0,0 0,25.347 25.531L29.9,30.083A1.411,1.411 0,0 0,31.896 30.083L32.332,29.647A1.411,1.411 0,0 0,32.332 27.651L27.78,23.098A1.411,1.411 0,0 0,25.784 23.098z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
</group>
|
||||
</vector>
|
||||
53
app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
53
app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:scaleX="0.7087745"
|
||||
android:scaleY="0.7087745"
|
||||
android:translateX="20.057142"
|
||||
android:translateY="20.37143">
|
||||
<path
|
||||
android:pathData="m28.56,57.594a22.542,22.542 0,0 1,4.775 -28.811,22.542 22.542,0 0,1 29.204,0.079 22.542,22.542 0,0 1,4.618 28.837"
|
||||
android:strokeWidth="6.61447"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m47.89,51.594c-1.387,0 -2.503,1.384 -2.503,3.102v11.899l-4.849,-4.849c-0.909,-0.909 -2.373,-0.909 -3.282,0l-0.257,0.257c-0.909,0.909 -0.909,2.373 0,3.282l9.173,9.173c0.466,0.466 1.078,0.692 1.686,0.681 0.011,0 0.022,0.002 0.033,0.002 0.011,0 0.022,-0.001 0.033,-0.002 0.608,0.012 1.22,-0.215 1.686,-0.681l9.173,-9.173c0.909,-0.909 0.909,-2.373 0,-3.282l-0.257,-0.257c-0.909,-0.909 -2.373,-0.909 -3.282,0l-4.849,4.849v-11.899c0,-1.719 -1.116,-3.102 -2.503,-3.102z"
|
||||
android:strokeWidth="5.40899"
|
||||
android:fillColor="#1B1B1B"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M41.407,40.614m-2.249,0a2.249,2.249 0,1 1,4.498 0a2.249,2.249 0,1 1,-4.498 0"
|
||||
android:strokeWidth="5.42057"
|
||||
android:fillColor="#FF000000"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M55.166,40.614m-2.249,0a2.249,2.249 0,1 1,4.498 0a2.249,2.249 0,1 1,-4.498 0"
|
||||
android:strokeWidth="5.42057"
|
||||
android:fillColor="#FF000000"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M69.466,23.098L69.903,23.535A1.411,1.411 90,0 1,69.903 25.531L65.35,30.083A1.411,1.411 0,0 1,63.355 30.083L62.918,29.647A1.411,1.411 0,0 1,62.918 27.651L67.47,23.098A1.411,1.411 0,0 1,69.466 23.098z"
|
||||
android:strokeWidth="8.92799"
|
||||
android:fillColor="#FF000000"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M25.784,23.098L25.347,23.535A1.411,1.411 0,0 0,25.347 25.531L29.9,30.083A1.411,1.411 0,0 0,31.896 30.083L32.332,29.647A1.411,1.411 0,0 0,32.332 27.651L27.78,23.098A1.411,1.411 0,0 0,25.784 23.098z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#FF000000"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
</group>
|
||||
</vector>
|
||||
209
app/src/main/res/drawable/tv_banner.xml
Normal file
209
app/src/main/res/drawable/tv_banner.xml
Normal file
@@ -0,0 +1,209 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="1280dp"
|
||||
android:height="720dp"
|
||||
android:viewportWidth="338.67"
|
||||
android:viewportHeight="190.5">
|
||||
<path
|
||||
android:pathData="M0,-0h338.67v190.5h-338.67z"
|
||||
android:strokeWidth="8.92799"
|
||||
android:fillColor="#191c1a"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m48.46,108.65a30.37,30.37 0,0 1,6.43 -38.82,30.37 30.37,0 0,1 39.35,0.11 30.37,30.37 0,0 1,6.22 38.85"
|
||||
android:strokeWidth="8.91"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#7ada9d"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m74.5,100.56c-1.87,0 -3.37,1.86 -3.37,4.18l0,16.03l-6.53,-6.53c-1.22,-1.22 -3.2,-1.22 -4.42,0l-0.35,0.35c-1.22,1.22 -1.22,3.2 0,4.42l12.36,12.36c0.63,0.63 1.45,0.93 2.27,0.92 0.01,0 0.03,0 0.04,0 0.01,0 0.03,-0 0.04,-0 0.82,0.02 1.64,-0.29 2.27,-0.92l12.36,-12.36c1.22,-1.22 1.22,-3.2 0,-4.42l-0.35,-0.35c-1.22,-1.22 -3.2,-1.22 -4.42,0l-6.53,6.53l0,-16.03c0,-2.32 -1.5,-4.18 -3.37,-4.18z"
|
||||
android:strokeWidth="7.29"
|
||||
android:fillColor="#ffb780"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M65.77,85.77m-3.03,0a3.03,3.03 0,1 1,6.06 0a3.03,3.03 0,1 1,-6.06 0"
|
||||
android:strokeWidth="7.3"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M84.31,85.77m-3.03,0a3.03,3.03 0,1 1,6.06 0a3.03,3.03 0,1 1,-6.06 0"
|
||||
android:strokeWidth="7.3"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M103.57,62.17L104.16,62.76A1.9,1.9 90,0 1,104.16 65.45L98.03,71.58A1.9,1.9 0,0 1,95.34 71.58L94.75,70.99A1.9,1.9 0,0 1,94.75 68.3L100.89,62.17A1.9,1.9 90,0 1,103.57 62.17z"
|
||||
android:strokeWidth="12.03"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M44.72,62.17L44.13,62.76A1.9,1.9 0,0 0,44.13 65.45L50.27,71.58A1.9,1.9 0,0 0,52.95 71.58L53.54,70.99A1.9,1.9 0,0 0,53.54 68.3L47.41,62.17A1.9,1.9 0,0 0,44.72 62.17z"
|
||||
android:strokeWidth="12.03"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillAlpha="0.964706"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m198.8,86.86q0,1.96 -0.86,3.55 -0.85,1.59 -2.27,2.47 -0.98,0.61 -2.2,0.88 -1.21,0.27 -3.18,0.27h-3.63L186.67,79.66h3.59q2.1,0 3.34,0.31 1.24,0.3 2.1,0.83 1.47,0.92 2.29,2.44 0.82,1.52 0.82,3.62zM196.81,86.83q0,-1.69 -0.59,-2.85 -0.59,-1.16 -1.76,-1.82 -0.85,-0.48 -1.8,-0.67 -0.95,-0.19 -2.29,-0.19h-1.79v11.08h1.79q1.38,0 2.4,-0.2 1.03,-0.2 1.89,-0.75 1.07,-0.68 1.6,-1.8 0.54,-1.12 0.54,-2.8z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m208.47,85.23h-0.1q-0.41,-0.1 -0.79,-0.14 -0.38,-0.05 -0.9,-0.05 -0.84,0 -1.62,0.38 -0.78,0.37 -1.5,0.95v7.65h-1.81L201.75,83.25h1.81v1.59q1.08,-0.87 1.9,-1.23 0.83,-0.37 1.69,-0.37 0.47,0 0.68,0.03 0.21,0.02 0.64,0.09z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m219.36,88.64q0,2.63 -1.35,4.16 -1.35,1.52 -3.62,1.52 -2.29,0 -3.64,-1.52 -1.34,-1.52 -1.34,-4.16 0,-2.63 1.34,-4.16 1.35,-1.53 3.64,-1.53 2.27,0 3.62,1.53 1.35,1.52 1.35,4.16zM217.49,88.64q0,-2.09 -0.82,-3.11 -0.82,-1.02 -2.28,-1.02 -1.48,0 -2.3,1.02 -0.81,1.01 -0.81,3.11 0,2.03 0.82,3.08 0.82,1.04 2.29,1.04 1.45,0 2.27,-1.03 0.83,-1.04 0.83,-3.09z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m224.12,81.45h-2.05v-1.88h2.05zM224,94.02h-1.81L222.19,83.25h1.81z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m236.33,94.02h-1.81v-1.13q-0.78,0.68 -1.63,1.05 -0.85,0.38 -1.84,0.38 -1.93,0 -3.07,-1.49 -1.13,-1.49 -1.13,-4.12 0,-1.37 0.39,-2.44 0.4,-1.07 1.06,-1.82 0.66,-0.73 1.52,-1.12 0.88,-0.39 1.81,-0.39 0.85,0 1.5,0.18 0.66,0.17 1.38,0.55v-4.67h1.81zM234.52,91.37v-6.18q-0.73,-0.33 -1.31,-0.45 -0.58,-0.13 -1.26,-0.13 -1.52,0 -2.37,1.06 -0.85,1.06 -0.85,3.01 0,1.92 0.66,2.92 0.66,0.99 2.1,0.99 0.77,0 1.56,-0.34 0.79,-0.35 1.48,-0.89z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m245.61,88.61h-6.02v-1.75h6.02z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m250.82,81.45h-2.05v-1.88h2.05zM250.7,94.02h-1.81L248.89,83.25h1.81z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m260.09,80.77h-0.1q-0.3,-0.09 -0.78,-0.17 -0.48,-0.1 -0.85,-0.1 -1.17,0 -1.7,0.52 -0.52,0.51 -0.52,1.86v0.37h3.27v1.52h-3.21v9.25h-1.81v-9.25h-1.23v-1.52h1.23v-0.36q0,-1.92 0.95,-2.94 0.95,-1.03 2.76,-1.03 0.61,0 1.09,0.06 0.49,0.06 0.9,0.14z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m270.46,83.25 l-6.29,14.75h-1.94l2.01,-4.5 -4.29,-10.25h1.97l3.31,7.99 3.34,-7.99z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#7ada9d"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m168.32,111.41h-1.16l-0.8,-2.29h-3.55l-0.8,2.29h-1.11l2.99,-8.21h1.46zM166.01,108.19 L164.57,104.16 163.13,108.19z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m182.43,111.41h-1.04v-3.51q0,-0.4 -0.04,-0.77 -0.03,-0.37 -0.15,-0.59 -0.13,-0.24 -0.36,-0.36 -0.24,-0.12 -0.68,-0.12 -0.44,0 -0.87,0.22 -0.44,0.21 -0.87,0.55 0.02,0.13 0.03,0.3 0.01,0.17 0.01,0.33v3.94h-1.04v-3.51q0,-0.41 -0.04,-0.77 -0.03,-0.37 -0.15,-0.59 -0.13,-0.24 -0.36,-0.35 -0.24,-0.12 -0.68,-0.12 -0.42,0 -0.85,0.21 -0.42,0.21 -0.85,0.53v4.6h-1.04v-6.16h1.04v0.68q0.49,-0.4 0.96,-0.63 0.49,-0.23 1.03,-0.23 0.63,0 1.06,0.26 0.44,0.26 0.66,0.73 0.63,-0.53 1.15,-0.76 0.52,-0.24 1.11,-0.24 1.01,0 1.49,0.62 0.49,0.61 0.49,1.71z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m189.21,111.41h-1.03v-0.66q-0.14,0.09 -0.37,0.26 -0.23,0.17 -0.45,0.26 -0.26,0.13 -0.6,0.21 -0.34,0.09 -0.79,0.09 -0.83,0 -1.41,-0.55 -0.58,-0.55 -0.58,-1.41 0,-0.7 0.3,-1.13 0.3,-0.44 0.86,-0.68 0.56,-0.25 1.35,-0.34 0.79,-0.09 1.69,-0.13L188.18,107.18q0,-0.35 -0.13,-0.58 -0.12,-0.23 -0.35,-0.36 -0.22,-0.13 -0.53,-0.17 -0.31,-0.04 -0.64,-0.04 -0.41,0 -0.91,0.11 -0.5,0.1 -1.04,0.31h-0.06v-1.05q0.3,-0.08 0.88,-0.18 0.57,-0.1 1.13,-0.1 0.65,0 1.13,0.11 0.49,0.1 0.84,0.36 0.35,0.25 0.53,0.66 0.18,0.4 0.18,1zM188.18,109.9L188.18,108.18q-0.47,0.03 -1.12,0.08 -0.64,0.06 -1.01,0.16 -0.45,0.13 -0.72,0.4 -0.28,0.26 -0.28,0.73 0,0.53 0.32,0.8 0.32,0.26 0.98,0.26 0.55,0 1,-0.21 0.45,-0.21 0.84,-0.51z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m194.4,111.36q-0.29,0.08 -0.64,0.13 -0.34,0.05 -0.61,0.05 -0.94,0 -1.43,-0.51 -0.49,-0.51 -0.49,-1.63v-3.27h-0.7v-0.87h0.7v-1.77h1.04v1.77h2.14v0.87h-2.14v2.81q0,0.49 0.02,0.76 0.02,0.27 0.15,0.51 0.12,0.22 0.33,0.33 0.21,0.1 0.65,0.1 0.25,0 0.53,-0.07 0.28,-0.08 0.4,-0.13h0.06z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m200.81,108.44h-4.54q0,0.57 0.17,0.99 0.17,0.42 0.47,0.69 0.29,0.26 0.68,0.4 0.4,0.13 0.87,0.13 0.63,0 1.26,-0.25 0.64,-0.25 0.91,-0.5h0.06v1.13q-0.52,0.22 -1.07,0.37 -0.55,0.15 -1.15,0.15 -1.53,0 -2.39,-0.83 -0.86,-0.83 -0.86,-2.36 0,-1.51 0.82,-2.4 0.83,-0.89 2.17,-0.89 1.25,0 1.92,0.73 0.68,0.73 0.68,2.07zM199.8,107.65q-0.01,-0.82 -0.41,-1.26 -0.4,-0.45 -1.23,-0.45 -0.83,0 -1.33,0.49 -0.49,0.49 -0.56,1.22z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m206.22,106.38h-0.06q-0.23,-0.06 -0.45,-0.08 -0.21,-0.03 -0.51,-0.03 -0.48,0 -0.93,0.21 -0.45,0.21 -0.86,0.55v4.37h-1.04v-6.16h1.04v0.91q0.62,-0.5 1.09,-0.7 0.47,-0.21 0.96,-0.21 0.27,0 0.39,0.02 0.12,0.01 0.36,0.05z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m208.31,104.22h-1.17L207.14,103.15h1.17zM208.25,111.41h-1.04v-6.16h1.04z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m215.08,111.41h-1.03v-0.66q-0.14,0.09 -0.37,0.26 -0.23,0.17 -0.45,0.26 -0.26,0.13 -0.6,0.21 -0.34,0.09 -0.79,0.09 -0.83,0 -1.41,-0.55 -0.58,-0.55 -0.58,-1.41 0,-0.7 0.3,-1.13 0.3,-0.44 0.86,-0.68 0.56,-0.25 1.35,-0.34 0.79,-0.09 1.69,-0.13L214.05,107.18q0,-0.35 -0.13,-0.58 -0.12,-0.23 -0.35,-0.36 -0.22,-0.13 -0.53,-0.17 -0.31,-0.04 -0.64,-0.04 -0.41,0 -0.91,0.11 -0.5,0.1 -1.04,0.31h-0.06v-1.05q0.3,-0.08 0.88,-0.18 0.57,-0.1 1.13,-0.1 0.65,0 1.13,0.11 0.49,0.1 0.84,0.36 0.35,0.25 0.53,0.66 0.18,0.4 0.18,1zM214.05,109.9L214.05,108.18q-0.47,0.03 -1.12,0.08 -0.64,0.06 -1.01,0.16 -0.45,0.13 -0.72,0.4 -0.28,0.26 -0.28,0.73 0,0.53 0.32,0.8 0.32,0.26 0.98,0.26 0.55,0 1,-0.21 0.45,-0.21 0.84,-0.51z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m218.12,111.41h-1.04v-8.58h1.04z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m229.47,104.17h-4.15v2.32h3.57v0.97h-3.57v3.95h-1.09v-8.21h5.24z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m237.65,107.32q0,1.12 -0.49,2.03 -0.49,0.91 -1.3,1.41 -0.56,0.35 -1.26,0.5 -0.69,0.15 -1.82,0.15h-2.07v-8.21h2.05q1.2,0 1.91,0.18 0.71,0.17 1.2,0.47 0.84,0.52 1.31,1.39 0.47,0.87 0.47,2.07zM236.51,107.3q0,-0.96 -0.34,-1.63 -0.34,-0.66 -1,-1.04 -0.49,-0.28 -1.03,-0.38 -0.55,-0.11 -1.31,-0.11h-1.03v6.33h1.03q0.79,0 1.37,-0.12 0.59,-0.12 1.08,-0.43 0.61,-0.39 0.92,-1.03 0.31,-0.64 0.31,-1.6z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m243.17,106.38h-0.06q-0.23,-0.06 -0.45,-0.08 -0.21,-0.03 -0.51,-0.03 -0.48,0 -0.93,0.21 -0.45,0.21 -0.86,0.55v4.37h-1.04v-6.16h1.04v0.91q0.62,-0.5 1.09,-0.7 0.47,-0.21 0.96,-0.21 0.27,0 0.39,0.02 0.12,0.01 0.36,0.05z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m249.39,108.34q0,1.5 -0.77,2.38 -0.77,0.87 -2.07,0.87 -1.31,0 -2.08,-0.87 -0.77,-0.87 -0.77,-2.38 0,-1.5 0.77,-2.38 0.77,-0.88 2.08,-0.88 1.3,0 2.07,0.88 0.77,0.87 0.77,2.38zM248.32,108.34q0,-1.2 -0.47,-1.77 -0.47,-0.58 -1.3,-0.58 -0.84,0 -1.31,0.58 -0.46,0.58 -0.46,1.77 0,1.16 0.47,1.76 0.47,0.6 1.31,0.6 0.83,0 1.3,-0.59 0.47,-0.6 0.47,-1.76z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m252.11,104.22h-1.17L250.94,103.15h1.17zM252.05,111.41h-1.04v-6.16h1.04z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m259.09,111.41h-1.04v-0.64q-0.45,0.39 -0.93,0.6 -0.49,0.21 -1.05,0.21 -1.1,0 -1.75,-0.85 -0.64,-0.85 -0.64,-2.35 0,-0.78 0.22,-1.39 0.23,-0.61 0.61,-1.04 0.37,-0.42 0.87,-0.64 0.5,-0.22 1.04,-0.22 0.49,0 0.86,0.1 0.37,0.1 0.79,0.31v-2.67h1.04zM258.05,109.9v-3.53q-0.42,-0.19 -0.75,-0.26 -0.33,-0.07 -0.72,-0.07 -0.87,0 -1.36,0.61 -0.49,0.61 -0.49,1.72 0,1.1 0.37,1.67 0.37,0.57 1.2,0.57 0.44,0 0.89,-0.19 0.45,-0.2 0.84,-0.51z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m271.52,110.82q-0.3,0.13 -0.55,0.25 -0.24,0.12 -0.64,0.24 -0.34,0.1 -0.73,0.18 -0.39,0.08 -0.87,0.08 -0.89,0 -1.63,-0.25 -0.73,-0.25 -1.27,-0.79 -0.53,-0.52 -0.83,-1.33 -0.3,-0.81 -0.3,-1.88 0,-1.01 0.29,-1.81 0.29,-0.8 0.83,-1.35 0.52,-0.53 1.26,-0.82 0.74,-0.28 1.65,-0.28 0.66,0 1.32,0.16 0.66,0.16 1.47,0.56v1.3h-0.08q-0.68,-0.57 -1.34,-0.83 -0.67,-0.26 -1.43,-0.26 -0.62,0 -1.12,0.2 -0.5,0.2 -0.89,0.62 -0.38,0.41 -0.6,1.05 -0.21,0.63 -0.21,1.46 0,0.87 0.23,1.49 0.24,0.62 0.61,1.01 0.39,0.41 0.9,0.61 0.52,0.19 1.09,0.19 0.79,0 1.48,-0.27 0.69,-0.27 1.29,-0.81h0.08z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m274.03,111.41h-1.04v-8.58h1.04z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m277.19,104.22h-1.17L276.02,103.15h1.17zM277.13,111.41h-1.04v-6.16h1.04z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m284.33,108.44h-4.54q0,0.57 0.17,0.99 0.17,0.42 0.47,0.69 0.29,0.26 0.68,0.4 0.4,0.13 0.87,0.13 0.63,0 1.26,-0.25 0.64,-0.25 0.91,-0.5h0.06v1.13q-0.52,0.22 -1.07,0.37 -0.55,0.15 -1.15,0.15 -1.53,0 -2.39,-0.83 -0.86,-0.83 -0.86,-2.36 0,-1.51 0.82,-2.4 0.83,-0.89 2.17,-0.89 1.25,0 1.92,0.73 0.68,0.73 0.68,2.07zM283.32,107.65q-0.01,-0.82 -0.41,-1.26 -0.4,-0.45 -1.23,-0.45 -0.83,0 -1.33,0.49 -0.49,0.49 -0.56,1.22z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m291.05,111.41h-1.04v-3.51q0,-0.42 -0.05,-0.79 -0.05,-0.37 -0.18,-0.58 -0.14,-0.23 -0.4,-0.34 -0.26,-0.12 -0.67,-0.12 -0.42,0 -0.89,0.21 -0.46,0.21 -0.89,0.53v4.6L285.9,111.41v-6.16h1.04v0.68q0.49,-0.4 1,-0.63 0.52,-0.23 1.06,-0.23 1,0 1.52,0.6 0.52,0.6 0.52,1.73z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="m296.24,111.36q-0.29,0.08 -0.64,0.13 -0.34,0.05 -0.61,0.05 -0.94,0 -1.43,-0.51 -0.49,-0.51 -0.49,-1.63v-3.27h-0.7v-0.87h0.7v-1.77h1.04v1.77h2.14v0.87h-2.14v2.81q0,0.49 0.02,0.76 0.02,0.27 0.15,0.51 0.12,0.22 0.33,0.33 0.21,0.1 0.65,0.1 0.25,0 0.53,-0.07 0.28,-0.08 0.4,-0.13h0.06z"
|
||||
android:strokeWidth="8.928"
|
||||
android:fillColor="#666666"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M138.39,57.46L138.39,57.46A1.77,1.77 0,0 1,140.16 59.23L140.16,131.27A1.77,1.77 0,0 1,138.39 133.04L138.39,133.04A1.77,1.77 0,0 1,136.61 131.27L136.61,59.23A1.77,1.77 0,0 1,138.39 57.46z"
|
||||
android:strokeWidth="8.92799"
|
||||
android:fillColor="#f7b27c"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
||||
171
app/src/main/res/layout/app_detail_header.xml
Normal file
171
app/src/main/res/layout/app_detail_header.xml
Normal file
@@ -0,0 +1,171 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:backgroundTint="?colorSurfaceContainer"
|
||||
android:background="@drawable/background_border">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/app_icon"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
android:layout_margin="16dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:shapeAppearanceOverlay="@style/Shape.Medium" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/app_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="2"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="?textAppearanceHeadlineMedium"
|
||||
app:layout_constraintTop_toBottomOf="@id/app_icon" />
|
||||
|
||||
<TextSwitcher
|
||||
android:id="@+id/author_package_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="@id/app_name">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/author_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="?textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOutline" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/package_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="?textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOutline" />
|
||||
</TextSwitcher>
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/release_info"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="12dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/author_package_name">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/version_block"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/version"
|
||||
android:textAppearance="?textAppearanceTitleSmall" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/version"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="marquee"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?textAppearanceBodyMedium" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:id="@+id/divider1"
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginVertical="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/size_block"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/size"
|
||||
android:textAppearance="?textAppearanceTitleSmall" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/size"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="marquee"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?textAppearanceBodyMedium" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:id="@+id/divider2"
|
||||
android:layout_width="2dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginVertical="12dp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/dev_block"
|
||||
style="?attr/materialCardViewElevatedStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_margin="4dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@drawable/ic_source_code"
|
||||
app:shapeAppearanceOverlay="@style/Shape.Small"
|
||||
app:srcCompat="@drawable/ic_source_code" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="marquee"
|
||||
android:singleLine="true"
|
||||
android:text="@string/source_code"
|
||||
android:textAppearance="?textAppearanceLabelMedium" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/favourite"
|
||||
style="@style/FavouriteTheme"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:padding="16dp"
|
||||
app:icon="@drawable/favourite_icon"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
24
app/src/main/res/layout/download_status.xml
Normal file
24
app/src/main/res/layout/download_status.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:singleLine="true"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progress"
|
||||
style="@style/Theme.Progress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical" />
|
||||
|
||||
</LinearLayout>
|
||||
119
app/src/main/res/layout/edit_repository.xml
Normal file
119
app/src/main/res/layout/edit_repository.xml
Normal file
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:animateLayoutChanges="true">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@android:id/list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="?materialCardViewElevatedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/address_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/address"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/address"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:inputType="textNoSuggestions|textVisiblePassword" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/fingerprint"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/fingerprint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/username"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/username"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:autofillHints="username" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/password"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:autofillHints="password"
|
||||
android:inputType="textNoSuggestions|textVisiblePassword" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?android:attr/colorBackground"
|
||||
android:clickable="true"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
tools:ignore="KeyboardInaccessibleWidget">
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
style="@style/Theme.Progress"
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAlignment="center"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/checking_repository"
|
||||
android:textAppearance="?textAppearanceHeadlineSmall" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/skip"
|
||||
style="?materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/skip" />
|
||||
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
22
app/src/main/res/layout/enum_type.xml
Normal file
22
app/src/main/res/layout/enum_type.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="@dimen/shape_margin_large"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?textAppearanceBodyLarge" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/content"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?textAppearanceBodyMedium"
|
||||
android:textColor="?colorOutline" />
|
||||
</LinearLayout>
|
||||
10
app/src/main/res/layout/expand_view_button.xml
Normal file
10
app/src/main/res/layout/expand_view_button.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/expand_view_button"
|
||||
style="@style/Widget.Material3.Button.ElevatedButton"
|
||||
android:elevation="4dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginHorizontal="48dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:textAllCaps="true" />
|
||||
36
app/src/main/res/layout/fragment.xml
Normal file
36
app/src/main/res/layout/fragment.xml
Normal file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appbar_layout"
|
||||
style="?attr/appBarLayoutStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?colorSurfaceContainer"
|
||||
app:elevation="0dp"
|
||||
app:liftOnScroll="false">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
style="?attr/toolbarStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?actionBarSize"
|
||||
android:background="?colorSurfaceContainer"
|
||||
android:elevation="0dp" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/toolbar_extra"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/fragment_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?android:colorBackground"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
8
app/src/main/res/layout/install_button.xml
Normal file
8
app/src/main/res/layout/install_button.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/action"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
app:iconGravity="textStart" />
|
||||
42
app/src/main/res/layout/link_item.xml
Normal file
42
app/src/main/res/layout/link_item.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:scaleType="centerInside"
|
||||
android:tintMode="src_in"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/link"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:textSize="14sp" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
29
app/src/main/res/layout/permissions_item.xml
Normal file
29
app/src/main/res/layout/permissions_item.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:scaleType="centerInside"
|
||||
android:tint="?android:attr/textColorSecondary"
|
||||
android:tintMode="src_in"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:padding="16dp"
|
||||
android:textSize="16sp" />
|
||||
|
||||
</LinearLayout>
|
||||
68
app/src/main/res/layout/product_item.xml
Normal file
68
app/src/main/res/layout/product_item.xml
Normal file
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp"
|
||||
android:layout_marginHorizontal="@dimen/shape_margin_medium">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="10dp">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
app:shapeAppearanceOverlay="@style/Shape.Small"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="14dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?textAppearanceBody1" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="80dp"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?textAppearanceLabelMedium" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/summary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?textAppearanceBody2"
|
||||
android:textColor="?attr/colorOutline" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user