clean
This commit is contained in:
@@ -1,15 +0,0 @@
|
|||||||
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
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,32 +0,0 @@
|
|||||||
---
|
|
||||||
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
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
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
26
.github/ISSUE_TEMPLATE/quick-crash-report.md
vendored
@@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
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
77
.github/workflows/build_debug.yml
vendored
@@ -1,77 +0,0 @@
|
|||||||
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
72
.github/workflows/release_build.yml
vendored
@@ -1,72 +0,0 @@
|
|||||||
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
22
.gitignore
vendored
@@ -1,22 +0,0 @@
|
|||||||
*.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
674
LICENSE
@@ -1,674 +0,0 @@
|
|||||||
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
15
STATUS.md
@@ -1,15 +0,0 @@
|
|||||||
_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.
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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
9
app/proguard.pro
vendored
@@ -1,9 +0,0 @@
|
|||||||
-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
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
<?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>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,29 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,968 +0,0 @@
|
|||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,489 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,460 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package com.looker.droidify.model
|
|
||||||
|
|
||||||
class InstalledItem(
|
|
||||||
val packageName: String,
|
|
||||||
val version: String,
|
|
||||||
val versionCode: Long,
|
|
||||||
val signature: String
|
|
||||||
)
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.looker.droidify.model
|
|
||||||
|
|
||||||
data class ProductPreference(val ignoreUpdates: Boolean, val ignoreVersionCode: Long) {
|
|
||||||
fun shouldIgnoreUpdate(versionCode: Long): Boolean {
|
|
||||||
return ignoreUpdates || ignoreVersionCode == versionCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
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"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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
@@ -1,554 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
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}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,475 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
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() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,554 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,671 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
@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()
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
@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()
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:duration="175" />
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?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" />
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?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" />
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<?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