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