mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
no need for android app
This commit is contained in:
parent
c76adcbf9b
commit
5fd03233e1
142 changed files with 8 additions and 9203 deletions
|
|
@ -28,10 +28,4 @@
|
||||||
# PROTON_IMAP_PASSWORD=bridge-generated-password
|
# PROTON_IMAP_PASSWORD=bridge-generated-password
|
||||||
# PROTON_BRIDGE_HOST=protonmail-bridge
|
# PROTON_BRIDGE_HOST=protonmail-bridge
|
||||||
# PROTON_BRIDGE_PORT=143
|
# PROTON_BRIDGE_PORT=143
|
||||||
# PROTON_SUP_TOPIC=Proton Mail
|
# PROTON_SUP_TOPIC=Proton Mail
|
||||||
|
|
||||||
# Optional: Enable Android app integration for notifications
|
|
||||||
# When enabled, messages include app launch codes that SUP Android app intercepts to open
|
|
||||||
# the relevant app (Proton Mail or Home Assistant) when notification is tapped.
|
|
||||||
# Default: false
|
|
||||||
# ENABLE_ANDROID_INTEGRATION=true
|
|
||||||
55
.github/workflows/android-ci.yml
vendored
55
.github/workflows/android-ci.yml
vendored
|
|
@ -1,55 +0,0 @@
|
||||||
name: Android CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
paths:
|
|
||||||
- 'android/**'
|
|
||||||
- '.github/workflows/android-ci.yml'
|
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
paths:
|
|
||||||
- 'android/**'
|
|
||||||
- '.github/workflows/android-ci.yml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup JDK
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: 'temurin'
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/actions/setup-gradle@v4
|
|
||||||
|
|
||||||
- name: Verify lockfile is up-to-date
|
|
||||||
run: |
|
|
||||||
cd android
|
|
||||||
chmod +x gradlew
|
|
||||||
./gradlew dependencies --write-locks
|
|
||||||
if ! git diff --exit-code app/gradle.lockfile; then
|
|
||||||
echo "Lockfile is out of date. Run: cd android && ./gradlew dependencies --write-locks"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Lockfile is up-to-date"
|
|
||||||
|
|
||||||
- name: Decode keystore
|
|
||||||
env:
|
|
||||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_FILE }}
|
|
||||||
run: |
|
|
||||||
echo "$KEYSTORE_BASE64" | base64 -d > android/release.keystore
|
|
||||||
|
|
||||||
- name: Build signed APK
|
|
||||||
env:
|
|
||||||
KEYSTORE_FILE: ${{ github.workspace }}/android/release.keystore
|
|
||||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
|
||||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
|
||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
cd android
|
|
||||||
./gradlew assembleRelease --build-cache --configuration-cache
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
# The Privacy Paradox of Self-Hosting
|
|
||||||
|
|
||||||
## Preface
|
|
||||||
|
|
||||||
A self-proclaimed infosec nerd blew my mind with this realization on the Graphene OS forums. Since then I've stopped self-hosting my mollysocket, ntfy, Vaultwarden and FMD servers. I ended up switching back to Bitwarden and also I'm now using Molly.im with WebSockets for push notifications. I realize that self-hosting is often talked about as a great way to protect your privacy and fully own your data (and metadata). I thought this would be fun to argue that you should almost never self-host anything and especially if you intend to expose and access it on the internet.
|
|
||||||
|
|
||||||
## Network footprinting
|
|
||||||
|
|
||||||
As we use our devices we leave a digital footprint on the network. This footprint is composed of the requests that we make to the digital services that we interact with. Sure, there are some ways to muddy this footprint by using Tor and/or VPNs.
|
|
||||||
|
|
||||||
There are very definite drawbacks to using such tools as Tor is dreadfully slow, which makes it very painful for everyday use especially for things like push notifications which one would expect to work quickly. VPNs shift the trust to your provider who is another party that you will need to trust to not invade your privacy and one that you will likely need to pay.
|
|
||||||
|
|
||||||
Using either of these options can make you a target for deeper surveillance as neither of these options will fully mask your use of them. A network sniffing adversary might start to wonder what you're trying to hide by needing to use such niche tools to muddy your network footprint.
|
|
||||||
|
|
||||||
## The network footprint of self-hosted systems
|
|
||||||
|
|
||||||
Unless you enjoy typing out IP addresses, you will likely need to register your self-hosted system to a domain which will be registered with the DNS. This domain may very well be linked back to you either through your payment or through the registrar's KYC system.
|
|
||||||
|
|
||||||
Even if you somehow don't use a domain name, you will be broadcasting your connection to a unique IP address that a network sniffer could pick up on. If that unique connection ever gets linked to the real you, like through your domain registration or other means, an adversary could track your location as you move around and connect to different networks like WiFi at cafes, libraries, airports, etc…
|
|
||||||
|
|
||||||
## My experience with degoogling and using UnifiedPush
|
|
||||||
|
|
||||||
I am fully degoogled meaning I run an Android OS (Graphene OS) without the Google Play services. The biggest challenge here is that Google Play services bundle in their Firebase Cloud Messaging, which you won't have resulting in the lack of push notifications for your apps. The community solution for this is to use the UnifiedPush system which requires you to download an UP distributor app like ntfy, which will maintain a single WebSocket connection to an UP server that will forward to you any notifications that you may get from any of the supporting apps. One such app that I frequently use is Molly which is a fork of Signal with great support for degoogled users like myself. Molly supports UP, FCM and direct WS connections to the Signal network.
|
|
||||||
|
|
||||||
As I started tracking the connections that my phone makes, I noticed that I'm constantly making connections to a super niche UP server to await for push notifications. These connections are a technical requirement as WS must routinely confirm the client's connection by sending out heartbeat messages. In my case I was also self-hosting a private ntfy server (together with a Molly-required MollySocket server) that was served from my private domain. I realized that this is very poor privacy hygiene as anything that was sniffing my network could easily figure out that it's my requests based on the very unique URL.
|
|
||||||
|
|
||||||
## My solution: SUP bro
|
|
||||||
|
|
||||||
Signal UnifiedPush will consist of an Android app that will act as an UP distributor. It will listen on Signal notifications to potentially act on them like to wake an app to refresh its data.
|
|
||||||
|
|
||||||
SUP will also consist of a strictly self-hostable server component that will proxy the UP push notifications through Signal groups instead of through an UP server like ntfy.sh. This server must be self-hosted as it will be linked as a new device to the user's Signal account to receive the notifications. After the initial setup, the server will not need to be exposed to the internet as it will only make outbound connections to the Signal servers.
|
|
||||||
|
|
||||||
## Closing thoughts
|
|
||||||
|
|
||||||
Self-hosting is still valuable for many use cases, especially when services don't need to be accessed over the internet. However, when it comes to services you access remotely, the privacy trade-offs are often worse than using established providers that blend your traffic with millions of other users.
|
|
||||||
|
|
||||||
The SUP project aims to provide a middle ground: the privacy benefits of using a widely-adopted service (Signal) while maintaining the control and ownership that self-hosting provides.
|
|
||||||
|
|
||||||
26
README.md
26
README.md
|
|
@ -24,10 +24,6 @@ SUP also includes an optional Proton Mail integration, allowing you to receive e
|
||||||
|
|
||||||
Note that you'll need to run SUP on your own server at home since it uses your personal Signal and Proton Mail credentials. A Raspberry Pi works perfectly for this, using minimal power (3-5W) while running SUP 24/7.
|
Note that you'll need to run SUP on your own server at home since it uses your personal Signal and Proton Mail credentials. A Raspberry Pi works perfectly for this, using minimal power (3-5W) while running SUP 24/7.
|
||||||
|
|
||||||
> **<EFBFBD> Background:** For a detailed explanation of the privacy paradox of self-hosting that motivated this project, see [MOTIVATION.md](MOTIVATION.md).
|
|
||||||
|
|
||||||
> **<EFBFBD>💡 Privacy Tip:** Use [Molly.im](https://molly.im/) (hardened Signal fork) with **WebSocket** notifications instead of the official Signal app. This ensures all Signal traffic, including SUP notifications, goes through WebSockets, making it indistinguishable from regular Signal messages.
|
|
||||||
|
|
||||||
## How?
|
## How?
|
||||||
|
|
||||||
SUP functions as a UnifiedPush server to proxy http-based requests to Signal groups via [signal-cli](https://github.com/AsamK/signal-cli).
|
SUP functions as a UnifiedPush server to proxy http-based requests to Signal groups via [signal-cli](https://github.com/AsamK/signal-cli).
|
||||||
|
|
@ -36,21 +32,7 @@ For the optional Proton Mail integration, SUP requires a server that runs Proton
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### 1. Install Android App (Optional)
|
### 1. Proton Mail Integration
|
||||||
|
|
||||||
> ⚠️ **Early Alpha**: The Android app is currently unavailable, but is planned for the full release of SUP.
|
|
||||||
|
|
||||||
An Android app is optionally available to connect UnifiedPush Android apps to the SUP server. It can also provide a better experience for displaying SUP-based notifications if the `ENABLE_ANDROID_INTEGRATION` environment variable is enabled on the server.
|
|
||||||
|
|
||||||
Download the latest APK from [GitHub Releases](https://github.com/lone-cloud/sup/releases).
|
|
||||||
|
|
||||||
**Certificate Fingerprint:**
|
|
||||||
|
|
||||||
```text
|
|
||||||
0D:3C:99:15:0E:12:1A:DE:0D:AE:05:CB:16:46:5E:65:31:56:DC:D6:98:87:59:4E:79:B1:0D:AE:1E:56:F2:E8
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Proton Mail Integration (Optional)
|
|
||||||
|
|
||||||
> ⚠️ **Early Alpha**: Currently only `docker-compose.dev.yml` dev deployments are available.
|
> ⚠️ **Early Alpha**: Currently only `docker-compose.dev.yml` dev deployments are available.
|
||||||
|
|
||||||
|
|
@ -100,7 +82,7 @@ Your phone will now receive Signal notifications when Proton Mail receives new e
|
||||||
|
|
||||||
Note that the bridge will first need to sync all of your old emails before you can start getting new email notifications which may take a while, but this is a one-time setup.
|
Note that the bridge will first need to sync all of your old emails before you can start getting new email notifications which may take a while, but this is a one-time setup.
|
||||||
|
|
||||||
### 3. Install SUP Server
|
### 2. Install SUP Server
|
||||||
|
|
||||||
> ⚠️ **Early Alpha**: Currently only `docker-compose.dev.yml` dev deployments are available.
|
> ⚠️ **Early Alpha**: Currently only `docker-compose.dev.yml` dev deployments are available.
|
||||||
|
|
||||||
|
|
@ -120,7 +102,7 @@ docker compose up -d
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Link Your Signal Account
|
### 3. Link Your Signal Account
|
||||||
|
|
||||||
Visit <http://localhost:8080> and link your Signal account (one-time setup):
|
Visit <http://localhost:8080> and link your Signal account (one-time setup):
|
||||||
|
|
||||||
|
|
@ -226,5 +208,3 @@ SUP consists of two services that **MUST run together on the same machine**:
|
||||||
- **protonmail-bridge** (Official Proton, optional): Decrypts Proton Mail emails, runs local IMAP server
|
- **protonmail-bridge** (Official Proton, optional): Decrypts Proton Mail emails, runs local IMAP server
|
||||||
|
|
||||||
All services communicate over a private Docker network with no external exposure except Signal protocol. **Separating these services across multiple machines would expose plaintext IMAP traffic and compromise security.**
|
All services communicate over a private Docker network with no external exposure except Signal protocol. **Separating these services across multiple machines would expose plaintext IMAP traffic and compromise security.**
|
||||||
|
|
||||||
**Android App** (Kotlin): Monitors Signal notifications, extracts UnifiedPush payloads, delivers to apps
|
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
kotlin version: 2.0.21
|
|
||||||
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing /home/eggy/Projects/sup/android/app/src/main/java/com/lonecloud/sup/DistributorService.kt:29:5: java.lang.IllegalArgumentException: source must not be null
|
|
||||||
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
|
|
||||||
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
|
|
||||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
|
||||||
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
|
|
||||||
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
|
|
||||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
|
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
|
|
||||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:351)
|
|
||||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:166)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:543)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:744)
|
|
||||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:623)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090)
|
|
||||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614)
|
|
||||||
at java.base/java.lang.Thread.run(Thread.java:1474)
|
|
||||||
Caused by: java.lang.IllegalArgumentException: source must not be null
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
|
|
||||||
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
|
|
||||||
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
|
|
||||||
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
|
|
||||||
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
|
|
||||||
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
|
|
||||||
... 28 more
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
id("com.google.devtools.ksp") version "2.1.0-1.0.29"
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.lonecloud.sup"
|
|
||||||
compileSdk = 36
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "com.lonecloud.sup"
|
|
||||||
minSdk = 26
|
|
||||||
targetSdk = 36
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "0.1.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
create("release") {
|
|
||||||
storeFile = file(System.getenv("KEYSTORE_FILE") ?: "../release.keystore")
|
|
||||||
storePassword = System.getenv("KEYSTORE_PASSWORD")
|
|
||||||
keyAlias = System.getenv("KEY_ALIAS") ?: "sup-release"
|
|
||||||
keyPassword = System.getenv("KEYSTORE_PASSWORD") // PKCS12 uses same password
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
isMinifyEnabled = true
|
|
||||||
isShrinkResources = true
|
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
|
||||||
signingConfig = signingConfigs.getByName("release")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
viewBinding = true
|
|
||||||
buildConfig = true
|
|
||||||
}
|
|
||||||
|
|
||||||
lint {
|
|
||||||
checkReleaseBuilds = false
|
|
||||||
abortOnError = false
|
|
||||||
}
|
|
||||||
|
|
||||||
ksp {
|
|
||||||
arg("room.schemaLocation", "$projectDir/schemas")
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
// Build config fields
|
|
||||||
buildConfigField("boolean", "FIREBASE_AVAILABLE", "false")
|
|
||||||
buildConfigField("boolean", "RATE_APP_AVAILABLE", "false")
|
|
||||||
buildConfigField("boolean", "PAYMENT_LINKS_AVAILABLE", "false")
|
|
||||||
buildConfigField("String", "FLAVOR", "\"sup\"")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
// AndroidX Core
|
|
||||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
|
||||||
implementation("androidx.core:core-ktx:1.17.0")
|
|
||||||
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
|
||||||
implementation("androidx.activity:activity-ktx:1.12.2")
|
|
||||||
implementation("androidx.fragment:fragment-ktx:1.8.9")
|
|
||||||
|
|
||||||
// JSON (Gson)
|
|
||||||
implementation("com.google.code.gson:gson:2.13.1")
|
|
||||||
|
|
||||||
// Room (SQLite)
|
|
||||||
val roomVersion = "2.8.4"
|
|
||||||
implementation("androidx.room:room-runtime:$roomVersion")
|
|
||||||
ksp("androidx.room:room-compiler:$roomVersion")
|
|
||||||
implementation("androidx.room:room-ktx:$roomVersion")
|
|
||||||
|
|
||||||
// OkHttp
|
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
|
||||||
|
|
||||||
// RecyclerView
|
|
||||||
implementation("androidx.recyclerview:recyclerview:1.4.0")
|
|
||||||
|
|
||||||
// Material Design
|
|
||||||
implementation("com.google.android.material:material:1.13.0")
|
|
||||||
|
|
||||||
// LiveData
|
|
||||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.10.0")
|
|
||||||
|
|
||||||
// UnifiedPush
|
|
||||||
implementation("com.github.UnifiedPush:android-connector:3.0.10")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
# This is a Gradle generated file for dependency locking.
|
|
||||||
# Manual edits can break the build and are not advised.
|
|
||||||
# This file is expected to be part of source control.
|
|
||||||
androidx.activity:activity-ktx:1.12.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.activity:activity:1.12.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,implementationDependenciesMetadata
|
|
||||||
androidx.annotation:annotation-experimental:1.5.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.annotation:annotation-jvm:1.9.1=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.annotation:annotation:1.0.0=apiDependenciesMetadata
|
|
||||||
androidx.annotation:annotation:1.9.1=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.appcompat:appcompat-resources:1.7.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.appcompat:appcompat:1.7.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.cardview:cardview:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.collection:collection-ktx:1.5.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.compose.runtime:runtime-annotation-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.compose.runtime:runtime-annotation:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.concurrent:concurrent-futures:1.1.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.constraintlayout:constraintlayout-core:1.1.1=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.constraintlayout:constraintlayout:2.2.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.coordinatorlayout:coordinatorlayout:1.1.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.core:core-ktx:1.17.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.core:core:1.17.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.customview:customview-poolingcontainer:1.0.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.customview:customview:1.1.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.databinding:viewbinding:8.9.1=apiDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.drawerlayout:drawerlayout:1.1.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.dynamicanimation:dynamicanimation:1.1.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.emoji2:emoji2-views-helper:1.3.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.emoji2:emoji2:1.3.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.fragment:fragment-ktx:1.8.9=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.fragment:fragment:1.8.9=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.graphics:graphics-shapes-android:1.0.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.graphics:graphics-shapes:1.0.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.interpolator:interpolator:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-common-jvm:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-common:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-livedata-core:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-livedata-ktx:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-livedata:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-process:2.10.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-runtime-android:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-runtime-ktx:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-runtime:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-viewmodel-android:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.lifecycle:lifecycle-viewmodel:2.10.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.navigationevent:navigationevent-android:1.0.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.navigationevent:navigationevent:1.0.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.profileinstaller:profileinstaller:1.4.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.recyclerview:recyclerview:1.4.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.room:room-common-jvm:2.8.4=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.room:room-common:2.8.4=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.room:room-compiler-processing:2.8.4=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
androidx.room:room-compiler:2.8.4=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
androidx.room:room-external-antlr:2.8.4=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
androidx.room:room-ktx:2.8.4=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.room:room-migration-jvm:2.8.4=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
androidx.room:room-migration:2.8.4=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
androidx.room:room-runtime-android:2.8.4=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.room:room-runtime:2.8.4=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.sqlite:sqlite-android:2.6.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.sqlite:sqlite-framework-android:2.6.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.sqlite:sqlite-framework:2.6.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.sqlite:sqlite:2.6.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.startup:startup-runtime:1.1.1=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.tracing:tracing:1.2.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.transition:transition:1.5.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.vectordrawable:vectordrawable:1.1.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.viewpager2:viewpager2:1.0.0=debugAndroidTestCompileClasspath,implementationDependenciesMetadata
|
|
||||||
androidx.viewpager2:viewpager2:1.1.0-beta02=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
androidx.viewpager:viewpager:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
com.android.tools.ddms:ddmlib:31.9.1=_internal-unified-test-platform-android-device-provider-ddmlib
|
|
||||||
com.android.tools.emulator:proto:31.9.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention
|
|
||||||
com.android.tools.utp:android-device-provider-ddmlib-proto:31.9.1=_internal-unified-test-platform-android-device-provider-ddmlib
|
|
||||||
com.android.tools.utp:android-device-provider-ddmlib:31.9.1=_internal-unified-test-platform-android-device-provider-ddmlib
|
|
||||||
com.android.tools.utp:android-device-provider-gradle-proto:31.9.1=_internal-unified-test-platform-android-device-provider-gradle
|
|
||||||
com.android.tools.utp:android-device-provider-gradle:31.9.1=_internal-unified-test-platform-android-device-provider-gradle
|
|
||||||
com.android.tools.utp:android-device-provider-profile-proto:31.9.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle
|
|
||||||
com.android.tools.utp:android-device-provider-profile:31.9.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle
|
|
||||||
com.android.tools.utp:android-test-plugin-host-additional-test-output-proto:31.9.1=_internal-unified-test-platform-android-test-plugin-host-additional-test-output
|
|
||||||
com.android.tools.utp:android-test-plugin-host-additional-test-output:31.9.1=_internal-unified-test-platform-android-test-plugin-host-additional-test-output
|
|
||||||
com.android.tools.utp:android-test-plugin-host-apk-installer-proto:31.9.1=_internal-unified-test-platform-android-test-plugin-host-apk-installer
|
|
||||||
com.android.tools.utp:android-test-plugin-host-apk-installer:31.9.1=_internal-unified-test-platform-android-test-plugin-host-apk-installer
|
|
||||||
com.android.tools.utp:android-test-plugin-host-coverage-proto:31.9.1=_internal-unified-test-platform-android-test-plugin-host-coverage
|
|
||||||
com.android.tools.utp:android-test-plugin-host-coverage:31.9.1=_internal-unified-test-platform-android-test-plugin-host-coverage
|
|
||||||
com.android.tools.utp:android-test-plugin-host-device-info-proto:31.9.1=_internal-unified-test-platform-android-test-plugin-host-device-info
|
|
||||||
com.android.tools.utp:android-test-plugin-host-device-info:31.9.1=_internal-unified-test-platform-android-test-plugin-host-device-info
|
|
||||||
com.android.tools.utp:android-test-plugin-host-emulator-control-proto:31.9.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control
|
|
||||||
com.android.tools.utp:android-test-plugin-host-emulator-control:31.9.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control
|
|
||||||
com.android.tools.utp:android-test-plugin-host-logcat-proto:31.9.1=_internal-unified-test-platform-android-test-plugin-host-logcat
|
|
||||||
com.android.tools.utp:android-test-plugin-host-logcat:31.9.1=_internal-unified-test-platform-android-test-plugin-host-logcat
|
|
||||||
com.android.tools.utp:android-test-plugin-host-retention-proto:31.9.1=_internal-unified-test-platform-android-test-plugin-host-retention
|
|
||||||
com.android.tools.utp:android-test-plugin-host-retention:31.9.1=_internal-unified-test-platform-android-test-plugin-host-retention
|
|
||||||
com.android.tools.utp:android-test-plugin-result-listener-gradle-proto:31.9.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
com.android.tools.utp:android-test-plugin-result-listener-gradle:31.9.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
com.android.tools.utp:utp-common:31.9.1=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention
|
|
||||||
com.android.tools:annotations:31.9.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
com.android.tools:common:31.9.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
com.github.UnifiedPush:android-connector:3.0.10=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
com.google.android.material:material:1.13.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
com.google.android:annotations:4.1.1.4=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
com.google.api.grpc:proto-google-common-protos:2.17.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
com.google.auto.service:auto-service-annotations:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
com.google.auto.service:auto-service:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
com.google.auto.value:auto-value-annotations:1.6.3=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
com.google.auto:auto-common:1.2.1=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
com.google.code.findbugs:jsr305:3.0.2=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
com.google.code.gson:gson:2.10.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
com.google.code.gson:gson:2.13.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
com.google.code.gson:gson:2.8.9=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher
|
|
||||||
com.google.crypto.tink:tink:1.17.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
com.google.crypto.tink:tink:1.7.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control
|
|
||||||
com.google.dagger:dagger:2.48=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
com.google.devtools.ksp:symbol-processing-api:2.0.10-1.0.24=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
com.google.devtools.ksp:symbol-processing-api:2.1.0-1.0.29=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kspPluginClasspath,kspPluginClasspathNonEmbeddable
|
|
||||||
com.google.devtools.ksp:symbol-processing-cmdline:2.1.0-1.0.29=kspPluginClasspathNonEmbeddable
|
|
||||||
com.google.devtools.ksp:symbol-processing:2.1.0-1.0.29=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kspPluginClasspath
|
|
||||||
com.google.errorprone:error_prone_annotations:2.23.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
com.google.errorprone:error_prone_annotations:2.26.1=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
com.google.errorprone:error_prone_annotations:2.38.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
com.google.guava:failureaccess:1.0.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
com.google.guava:failureaccess:1.0.2=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
com.google.guava:guava:32.0.1-jre=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
com.google.guava:guava:33.2.1-jre=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
com.google.j2objc:j2objc-annotations:2.8=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
com.google.protobuf:protobuf-java-util:3.22.3=_internal-unified-test-platform-core
|
|
||||||
com.google.protobuf:protobuf-java-util:3.24.4=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-launcher
|
|
||||||
com.google.protobuf:protobuf-java:3.24.4=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
com.google.protobuf:protobuf-java:4.28.2=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
com.google.protobuf:protobuf-kotlin:3.24.4=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
com.google.testing.platform:android-device-provider-local:0.0.9-alpha03=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
com.google.testing.platform:android-driver-instrumentation:0.0.9-alpha03=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-emulator-control
|
|
||||||
com.google.testing.platform:android-test-plugin:0.0.9-alpha03=_internal-unified-test-platform-android-test-plugin
|
|
||||||
com.google.testing.platform:core-proto:0.0.9-alpha03=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
com.google.testing.platform:core:0.0.9-alpha03=_internal-unified-test-platform-core
|
|
||||||
com.google.testing.platform:launcher:0.0.9-alpha03=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-launcher
|
|
||||||
com.intellij:annotations:12.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
com.squareup.okhttp3:okhttp:4.12.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
com.squareup.okio:okio-jvm:3.6.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
com.squareup.okio:okio:3.6.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
com.squareup:javapoet:1.13.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
com.squareup:kotlinpoet-javapoet:2.0.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
com.squareup:kotlinpoet-jvm:2.0.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
com.squareup:kotlinpoet:2.0.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
commons-codec:commons-codec:1.15=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
commons-io:commons-io:2.16.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention
|
|
||||||
io.grpc:grpc-api:1.57.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.grpc:grpc-context:1.57.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.grpc:grpc-core:1.57.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.grpc:grpc-netty:1.57.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.grpc:grpc-protobuf-lite:1.57.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.grpc:grpc-protobuf:1.57.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.grpc:grpc-services:1.57.2=_internal-unified-test-platform-core
|
|
||||||
io.grpc:grpc-stub:1.57.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.netty:netty-buffer:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.netty:netty-codec-http2:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.netty:netty-codec-http:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.netty:netty-codec-socks:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.netty:netty-codec:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.netty:netty-common:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.netty:netty-handler-proxy:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.netty:netty-handler:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.netty:netty-resolver:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.netty:netty-transport-native-unix-common:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.netty:netty-transport:4.1.93.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
io.opencensus:opencensus-api:0.31.0=_internal-unified-test-platform-core
|
|
||||||
io.opencensus:opencensus-proto:0.2.0=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
io.perfmark:perfmark-api:0.26.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
javax.annotation:javax.annotation-api:1.3.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
javax.inject:javax.inject:1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
net.java.dev.jna:jna-platform:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
net.java.dev.jna:jna:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
net.sf.kxml:kxml2:2.3.0=_internal-unified-test-platform-android-device-provider-ddmlib
|
|
||||||
org.checkerframework:checker-qual:3.33.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
org.checkerframework:checker-qual:3.42.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
org.codehaus.mojo:animal-sniffer-annotations:1.23=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core
|
|
||||||
org.jetbrains.intellij.deps:trove4j:1.0.20200330=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-bom:1.8.22=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-build-common:2.1.0=kotlinBuildToolsApiClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-build-tools-api:2.1.0=kotlinBuildToolsApiClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-build-tools-impl:2.1.0=kotlinBuildToolsApiClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.1.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-compiler-runner:2.1.0=kotlinBuildToolsApiClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-daemon-client:2.1.0=kotlinBuildToolsApiClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.1.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.1.0=kotlinKlibCommonizerClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-metadata-jvm:2.2.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-reflect:1.8.21=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
org.jetbrains.kotlin:kotlin-reflect:2.0.10=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-script-runtime:2.1.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-scripting-common:2.1.0=kotlinBuildToolsApiClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.1.0=kotlinBuildToolsApiClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.1.0=kotlinBuildToolsApiClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-scripting-jvm:2.1.0=kotlinBuildToolsApiClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21=_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-launcher
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-common:2.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-common:2.1.20=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21=implementationDependenciesMetadata
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.21=implementationDependenciesMetadata
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib:1.8.21=_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib:1.9.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-launcher
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib:2.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,apiDependenciesMetadata,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kotlinKlibCommonizerClasspath,kspPluginClasspath,kspPluginClasspathNonEmbeddable
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib:2.1.20=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib:2.2.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-launcher
|
|
||||||
org.jetbrains.kotlinx:atomicfu:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-launcher
|
|
||||||
org.jetbrains.kotlinx:atomicfu:0.27.0=implementationDependenciesMetadata
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.8.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspPluginClasspath,kspPluginClasspathNonEmbeddable,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.8.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspPluginClasspath,kspPluginClasspathNonEmbeddable,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspPluginClasspath,kspPluginClasspathNonEmbeddable,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspPluginClasspath,kspPluginClasspathNonEmbeddable,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspPluginClasspath,kspPluginClasspathNonEmbeddable,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
org.jetbrains:annotations:13.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kotlinKlibCommonizerClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspPluginClasspath,kspPluginClasspathNonEmbeddable,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
org.jetbrains:annotations:23.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.jspecify:jspecify:1.0.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,implementationDependenciesMetadata,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
|
|
||||||
org.xerial:sqlite-jdbc:3.41.2.2=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
|
|
||||||
empty=_agp_internal_javaPreCompileDebugAndroidTest_kspClasspath,_agp_internal_javaPreCompileDebugUnitTest_kspClasspath,_agp_internal_javaPreCompileReleaseUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestApiDependenciesMetadata,androidTestCompileOnlyDependenciesMetadata,androidTestDebugApiDependenciesMetadata,androidTestDebugCompileOnlyDependenciesMetadata,androidTestDebugImplementationDependenciesMetadata,androidTestDebugIntransitiveDependenciesMetadata,androidTestImplementationDependenciesMetadata,androidTestIntransitiveDependenciesMetadata,androidTestReleaseApiDependenciesMetadata,androidTestReleaseCompileOnlyDependenciesMetadata,androidTestReleaseImplementationDependenciesMetadata,androidTestReleaseIntransitiveDependenciesMetadata,androidTestUtil,compileOnlyDependenciesMetadata,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestApiDependenciesMetadata,debugAndroidTestCompileOnlyDependenciesMetadata,debugAndroidTestImplementationDependenciesMetadata,debugAndroidTestIntransitiveDependenciesMetadata,debugAndroidTestRuntimeClasspath,debugAnnotationProcessorClasspath,debugApiDependenciesMetadata,debugCompileOnlyDependenciesMetadata,debugImplementationDependenciesMetadata,debugIntransitiveDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestApiDependenciesMetadata,debugUnitTestCompileOnlyDependenciesMetadata,debugUnitTestImplementationDependenciesMetadata,debugUnitTestIntransitiveDependenciesMetadata,debugWearBundling,intransitiveDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseApiDependenciesMetadata,releaseCompileOnlyDependenciesMetadata,releaseImplementationDependenciesMetadata,releaseIntransitiveDependenciesMetadata,releaseReverseMetadataValues,releaseUnitTestAnnotationProcessorClasspath,releaseUnitTestApiDependenciesMetadata,releaseUnitTestCompileOnlyDependenciesMetadata,releaseUnitTestImplementationDependenciesMetadata,releaseUnitTestIntransitiveDependenciesMetadata,releaseWearBundling,testApiDependenciesMetadata,testCompileOnlyDependenciesMetadata,testDebugApiDependenciesMetadata,testDebugCompileOnlyDependenciesMetadata,testDebugImplementationDependenciesMetadata,testDebugIntransitiveDependenciesMetadata,testFixturesApiDependenciesMetadata,testFixturesCompileOnlyDependenciesMetadata,testFixturesDebugApiDependenciesMetadata,testFixturesDebugCompileOnlyDependenciesMetadata,testFixturesDebugImplementationDependenciesMetadata,testFixturesDebugIntransitiveDependenciesMetadata,testFixturesImplementationDependenciesMetadata,testFixturesIntransitiveDependenciesMetadata,testFixturesReleaseApiDependenciesMetadata,testFixturesReleaseCompileOnlyDependenciesMetadata,testFixturesReleaseImplementationDependenciesMetadata,testFixturesReleaseIntransitiveDependenciesMetadata,testImplementationDependenciesMetadata,testIntransitiveDependenciesMetadata,testReleaseApiDependenciesMetadata,testReleaseCompileOnlyDependenciesMetadata,testReleaseImplementationDependenciesMetadata,testReleaseIntransitiveDependenciesMetadata
|
|
||||||
22
android/app/proguard-rules.pro
vendored
22
android/app/proguard-rules.pro
vendored
|
|
@ -1,22 +0,0 @@
|
||||||
-dontwarn okhttp3.**
|
|
||||||
-dontwarn okio.**
|
|
||||||
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
|
||||||
|
|
||||||
-keepattributes *Annotation*, InnerClasses
|
|
||||||
-dontnote kotlinx.serialization.AnnotationsKt
|
|
||||||
-keepclassmembers class kotlinx.serialization.json.** {
|
|
||||||
*** Companion;
|
|
||||||
}
|
|
||||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
|
||||||
kotlinx.serialization.KSerializer serializer(...);
|
|
||||||
}
|
|
||||||
-keep,includedescriptorclasses class com.lonecloud.sup.**$$serializer { *; }
|
|
||||||
-keepclassmembers class com.lonecloud.sup.** {
|
|
||||||
*** Companion;
|
|
||||||
}
|
|
||||||
-keepclasseswithmembers class com.lonecloud.sup.** {
|
|
||||||
kotlinx.serialization.KSerializer serializer(...);
|
|
||||||
}
|
|
||||||
|
|
||||||
-keep class org.unifiedpush.android.connector.** { *; }
|
|
||||||
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
{
|
|
||||||
"formatVersion": 1,
|
|
||||||
"database": {
|
|
||||||
"version": 18,
|
|
||||||
"identityHash": "35364cf175ffcf5aa672eabaabe12397",
|
|
||||||
"entities": [
|
|
||||||
{
|
|
||||||
"tableName": "Subscription",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `insistent` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, PRIMARY KEY(`id`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "baseUrl",
|
|
||||||
"columnName": "baseUrl",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "topic",
|
|
||||||
"columnName": "topic",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "mutedUntil",
|
|
||||||
"columnName": "mutedUntil",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "minPriority",
|
|
||||||
"columnName": "minPriority",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "autoDelete",
|
|
||||||
"columnName": "autoDelete",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "insistent",
|
|
||||||
"columnName": "insistent",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "upAppId",
|
|
||||||
"columnName": "upAppId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "upConnectorToken",
|
|
||||||
"columnName": "upConnectorToken",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "displayName",
|
|
||||||
"columnName": "displayName",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": ["id"]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_Subscription_baseUrl_topic",
|
|
||||||
"unique": true,
|
|
||||||
"columnNames": ["baseUrl", "topic"],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_Subscription_upConnectorToken",
|
|
||||||
"unique": true,
|
|
||||||
"columnNames": ["upConnectorToken"],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "Notification",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`, `subscriptionId`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "subscriptionId",
|
|
||||||
"columnName": "subscriptionId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "timestamp",
|
|
||||||
"columnName": "timestamp",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "title",
|
|
||||||
"columnName": "title",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "message",
|
|
||||||
"columnName": "message",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "notificationId",
|
|
||||||
"columnName": "notificationId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "priority",
|
|
||||||
"columnName": "priority",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "tags",
|
|
||||||
"columnName": "tags",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "deleted",
|
|
||||||
"columnName": "deleted",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": ["id", "subscriptionId"]
|
|
||||||
},
|
|
||||||
"indices": [],
|
|
||||||
"foreignKeys": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "Log",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `level` INTEGER NOT NULL, `message` TEXT NOT NULL, `exception` TEXT)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "timestamp",
|
|
||||||
"columnName": "timestamp",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "tag",
|
|
||||||
"columnName": "tag",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "level",
|
|
||||||
"columnName": "level",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "message",
|
|
||||||
"columnName": "message",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "exception",
|
|
||||||
"columnName": "exception",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": ["id"]
|
|
||||||
},
|
|
||||||
"indices": [],
|
|
||||||
"foreignKeys": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"views": [],
|
|
||||||
"setupQueries": [
|
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
|
||||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '35364cf175ffcf5aa672eabaabe12397')"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
|
||||||
|
|
||||||
<queries>
|
|
||||||
<intent>
|
|
||||||
<action android:name="org.unifiedpush.android.connector.RAISE_TO_FOREGROUND" />
|
|
||||||
</intent>
|
|
||||||
</queries>
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:name=".app.Application"
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/AppTheme"
|
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
android:usesCleartextTraffic="true">
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".ui.MainActivity"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".ui.DetailActivity"
|
|
||||||
android:parentActivityName=".ui.MainActivity"
|
|
||||||
android:windowSoftInputMode="adjustResize"
|
|
||||||
android:exported="false">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.PARENT_ACTIVITY"
|
|
||||||
android:value=".ui.MainActivity"/>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<!-- Hack: Activity used for "view" action button with "clear=true" (to be able to cancel notifications and show a URL) -->
|
|
||||||
<activity
|
|
||||||
android:name=".msg.NotificationService$ViewActionWithClearActivity"
|
|
||||||
android:exported="false">
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<!-- UnifiedPush link activity to facilitate distributor selection, see https://unifiedpush.org/developers/spec/android/#link-activity -->
|
|
||||||
<activity android:name=".up.LinkActivity" android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:scheme="unifiedpush" android:host="link" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<!-- Signal listener foreground service -->
|
|
||||||
<service
|
|
||||||
android:name=".service.SignalListenerService"
|
|
||||||
android:foregroundServiceType="dataSync"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<!-- Broadcast receiver to send messages via intents -->
|
|
||||||
<receiver
|
|
||||||
android:name=".msg.BroadcastService$BroadcastReceiver"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="com.lonecloud.sup.SEND_MESSAGE"/>
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<!-- Broadcast receiver for UnifiedPush; must match https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md -->
|
|
||||||
<receiver
|
|
||||||
android:name=".up.BroadcastReceiver"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="org.unifiedpush.android.distributor.REGISTER"/>
|
|
||||||
<action android:name="org.unifiedpush.android.distributor.UNREGISTER"/>
|
|
||||||
<action android:name="org.unifiedpush.android.distributor.feature.BYTES_MESSAGE"/>
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<!-- Broadcast receiver for the "Download"/"Cancel" attachment action in the notification popup -->
|
|
||||||
<receiver
|
|
||||||
android:name=".msg.NotificationService$UserActionBroadcastReceiver"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="false">
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<!-- Broadcast receiver for when the notification is swiped away (currently only to cancel the insistent sound) -->
|
|
||||||
<receiver
|
|
||||||
android:name=".msg.NotificationService$DeleteBroadcastReceiver"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="false">
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<!-- FileProvider required for older Android versions (<= P), to allow passing the file URI in the open intent.
|
|
||||||
Avoids "exposed beyond app through Intent.getData" exception, see see https://stackoverflow.com/a/57288352/1440785 -->
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.provider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths"/>
|
|
||||||
</provider>
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
package com.lonecloud.sup
|
|
||||||
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.util.Log
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
class DistributorService : Service() {
|
|
||||||
private val client = OkHttpClient()
|
|
||||||
private val prefs by lazy {
|
|
||||||
getSharedPreferences("sup_prefs", MODE_PRIVATE)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
when (intent?.action) {
|
|
||||||
"org.unifiedpush.android.distributor.REGISTER" -> handleRegister(intent)
|
|
||||||
"org.unifiedpush.android.distributor.UNREGISTER" -> handleUnregister(intent)
|
|
||||||
}
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleRegister(intent: Intent) {
|
|
||||||
val token = intent.getStringExtra("token") ?: return
|
|
||||||
val appId = intent.getStringExtra("application") ?: return
|
|
||||||
|
|
||||||
Log.d("SUP", "Registering: app=$appId, token=$token")
|
|
||||||
|
|
||||||
val serverUrl = prefs.getString("server_url", null)
|
|
||||||
val apiKey = prefs.getString("api_key", null)
|
|
||||||
|
|
||||||
if (serverUrl == null) {
|
|
||||||
sendRegistrationRefused(token, "Server not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Thread {
|
|
||||||
try {
|
|
||||||
val json = JSONObject().apply {
|
|
||||||
put("appName", appId)
|
|
||||||
}
|
|
||||||
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("$serverUrl/up/$appId")
|
|
||||||
.post(json.toString().toRequestBody("application/json".toMediaType()))
|
|
||||||
.apply {
|
|
||||||
if (apiKey != null) {
|
|
||||||
addHeader("Authorization", "Bearer $apiKey")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val response = client.newCall(request).execute()
|
|
||||||
val responseBody = response.body?.string()
|
|
||||||
|
|
||||||
if (response.isSuccessful && responseBody != null) {
|
|
||||||
val jsonResponse = JSONObject(responseBody)
|
|
||||||
val endpoint = jsonResponse.getString("endpoint")
|
|
||||||
|
|
||||||
prefs.edit()
|
|
||||||
.putString("endpoint_$appId", endpoint)
|
|
||||||
.putString("token_$appId", token)
|
|
||||||
.apply()
|
|
||||||
|
|
||||||
sendEndpoint(token, endpoint)
|
|
||||||
Log.d("SUP", "Registered successfully: $endpoint")
|
|
||||||
} else {
|
|
||||||
sendRegistrationRefused(token, "Server error: ${response.code}")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("SUP", "Registration failed", e)
|
|
||||||
sendRegistrationRefused(token, "Failed: ${e.message}")
|
|
||||||
}
|
|
||||||
}.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleUnregister(intent: Intent) {
|
|
||||||
val token = intent.getStringExtra("token") ?: return
|
|
||||||
Log.d("SUP", "Unregistering: token=$token")
|
|
||||||
|
|
||||||
val allPrefs = prefs.all
|
|
||||||
for ((key, value) in allPrefs) {
|
|
||||||
if (key.startsWith("token_") && value == token) {
|
|
||||||
val appId = key.removePrefix("token_")
|
|
||||||
prefs.edit()
|
|
||||||
.remove("endpoint_$appId")
|
|
||||||
.remove("token_$appId")
|
|
||||||
.apply()
|
|
||||||
|
|
||||||
sendUnregistered(token)
|
|
||||||
Log.d("SUP", "Unregistered: $appId")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendEndpoint(token: String, endpoint: String) {
|
|
||||||
val intent = Intent("org.unifiedpush.android.connector.MESSAGE").apply {
|
|
||||||
putExtra("token", token)
|
|
||||||
putExtra("endpoint", endpoint)
|
|
||||||
`package` = getAppPackageFromToken(token)
|
|
||||||
}
|
|
||||||
sendBroadcast(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendRegistrationRefused(token: String, message: String) {
|
|
||||||
val intent = Intent("org.unifiedpush.android.connector.REGISTRATION_REFUSED").apply {
|
|
||||||
putExtra("token", token)
|
|
||||||
putExtra("message", message)
|
|
||||||
`package` = getAppPackageFromToken(token)
|
|
||||||
}
|
|
||||||
sendBroadcast(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendUnregistered(token: String) {
|
|
||||||
val intent = Intent("org.unifiedpush.android.connector.UNREGISTERED").apply {
|
|
||||||
putExtra("token", token)
|
|
||||||
`package` = getAppPackageFromToken(token)
|
|
||||||
}
|
|
||||||
sendBroadcast(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getAppPackageFromToken(token: String): String {
|
|
||||||
return token.split(":").firstOrNull() ?: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
package com.lonecloud.sup
|
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.service.notification.NotificationListenerService
|
|
||||||
import android.service.notification.StatusBarNotification
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.lonecloud.sup.db.Database
|
|
||||||
import com.lonecloud.sup.db.Notification
|
|
||||||
import com.lonecloud.sup.ui.MainActivity
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
class SignalNotificationListener : NotificationListenerService() {
|
|
||||||
|
|
||||||
private val TAG = "SUP_Listener"
|
|
||||||
private val prefs by lazy {
|
|
||||||
getSharedPreferences("sup_prefs", MODE_PRIVATE)
|
|
||||||
}
|
|
||||||
private val db by lazy { Database.getInstance(this) }
|
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val CHANNEL_ID = "sup_notifications"
|
|
||||||
private const val CHANNEL_NAME = "SUP Notifications"
|
|
||||||
private const val CHANNEL_ID_PROTON = "sup_email"
|
|
||||||
private const val CHANNEL_NAME_PROTON = "Email Notifications"
|
|
||||||
private const val SUP_ENDPOINT_PREFIX = "[SUP:"
|
|
||||||
private const val LAUNCH_PREFIX = "[LAUNCH:"
|
|
||||||
|
|
||||||
private val SIGNAL_PACKAGES = setOf(
|
|
||||||
"org.thoughtcrime.securesms", // Signal
|
|
||||||
"im.molly.app" // Molly
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
createNotificationChannels()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
serviceScope.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
|
||||||
if (sbn?.packageName !in SIGNAL_PACKAGES) return
|
|
||||||
|
|
||||||
val notification = sbn?.notification ?: return
|
|
||||||
val extras = notification.extras
|
|
||||||
|
|
||||||
val title = extras.getString("android.title") ?: ""
|
|
||||||
val text = extras.getCharSequence("android.text")?.toString() ?: ""
|
|
||||||
|
|
||||||
Log.d(TAG, "Signal notification: title=$title, text=$text")
|
|
||||||
|
|
||||||
when {
|
|
||||||
text.startsWith(SUP_ENDPOINT_PREFIX) -> {
|
|
||||||
parseAndDeliverUnifiedPush(text)
|
|
||||||
cancelNotification(sbn?.key ?: return)
|
|
||||||
}
|
|
||||||
text.startsWith(LAUNCH_PREFIX) -> {
|
|
||||||
parseAndShowLaunchNotification(text)
|
|
||||||
cancelNotification(sbn?.key ?: return)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseAndDeliverUnifiedPush(message: String) {
|
|
||||||
try {
|
|
||||||
val endpointMatch = Regex("""\[SUP:([^\]]+)\]""").find(message)
|
|
||||||
val endpointId = endpointMatch?.groupValues?.get(1) ?: run {
|
|
||||||
Log.w(TAG, "No endpoint ID found in message")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val subscription = runBlocking {
|
|
||||||
db.subscriptionDao().getByUpAppId(endpointId)
|
|
||||||
} ?: run {
|
|
||||||
Log.w(TAG, "No subscription found for upAppId: $endpointId")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val payload = message.substringAfter("]").trim()
|
|
||||||
|
|
||||||
val intent = Intent("org.unifiedpush.android.connector.MESSAGE").apply {
|
|
||||||
putExtra("token", subscription.upConnectorToken)
|
|
||||||
putExtra("message", payload)
|
|
||||||
`package` = subscription.upAppId
|
|
||||||
}
|
|
||||||
sendBroadcast(intent)
|
|
||||||
|
|
||||||
Log.d(TAG, "Delivered UnifiedPush notification to app: ${subscription.upAppId}")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to parse/deliver UnifiedPush notification", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseAndShowLaunchNotification(message: String) {
|
|
||||||
try {
|
|
||||||
val packageMatch = Regex("""\[LAUNCH:([^\]]+)\]""").find(message)
|
|
||||||
val packageName = packageMatch?.groupValues?.get(1) ?: run {
|
|
||||||
Log.w(TAG, "No package name found in LAUNCH message")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val content = message.substringAfter("]").trim()
|
|
||||||
|
|
||||||
// Parse title and body (format: **Title**\nBody)
|
|
||||||
val titleMatch = Regex("""\*\*([^*]+)\*\*""").find(content)
|
|
||||||
val title = titleMatch?.groupValues?.get(1) ?: "Email"
|
|
||||||
val body = content.substringAfter("**", "").substringAfter("**", "").trim()
|
|
||||||
|
|
||||||
// Check if target app is installed
|
|
||||||
val isInstalled = try {
|
|
||||||
packageManager.getPackageInfo(packageName, 0)
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
val clickIntent = if (isInstalled) {
|
|
||||||
packageManager.getLaunchIntentForPackage(packageName)?.let { intent ->
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
Random.nextInt(),
|
|
||||||
intent.apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID_PROTON)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(body)
|
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_email)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.apply {
|
|
||||||
if (clickIntent != null) {
|
|
||||||
setContentIntent(clickIntent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
notificationManager.notify(Random.nextInt(), notification)
|
|
||||||
|
|
||||||
Log.d(TAG, "Showed app launch notification")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to parse/show app launch notification", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannels() {
|
|
||||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
CHANNEL_NAME,
|
|
||||||
NotificationManager.IMPORTANCE_DEFAULT
|
|
||||||
).apply {
|
|
||||||
description = "Notifications from SUP topics"
|
|
||||||
}
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
|
|
||||||
val protonChannel = NotificationChannel(
|
|
||||||
CHANNEL_ID_PROTON,
|
|
||||||
CHANNEL_NAME_PROTON,
|
|
||||||
NotificationManager.IMPORTANCE_DEFAULT
|
|
||||||
).apply {
|
|
||||||
description = "Email notifications"
|
|
||||||
}
|
|
||||||
notificationManager.createNotificationChannel(protonChannel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
package com.lonecloud.sup.app
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import com.google.android.material.color.DynamicColors
|
|
||||||
import com.lonecloud.sup.db.Repository
|
|
||||||
import com.lonecloud.sup.util.Log
|
|
||||||
|
|
||||||
class Application : Application() {
|
|
||||||
val repository by lazy {
|
|
||||||
val repository = Repository.getInstance(applicationContext)
|
|
||||||
if (repository.getRecordLogs()) {
|
|
||||||
Log.setRecord(true)
|
|
||||||
}
|
|
||||||
repository
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
if (repository.getDynamicColorsEnabled()) {
|
|
||||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,453 +0,0 @@
|
||||||
package com.lonecloud.sup.db
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room.ColumnInfo
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.Ignore
|
|
||||||
import androidx.room.Index
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.OnConflictStrategy
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
import androidx.room.Query
|
|
||||||
import androidx.room.Room
|
|
||||||
import androidx.room.RoomDatabase
|
|
||||||
import androidx.room.Update
|
|
||||||
import androidx.room.migration.Migration
|
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
|
||||||
import com.lonecloud.sup.service.NotAuthorizedException
|
|
||||||
import com.lonecloud.sup.service.hasCause
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import java.net.ConnectException
|
|
||||||
|
|
||||||
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)])
|
|
||||||
data class Subscription(
|
|
||||||
@PrimaryKey val id: Long,
|
|
||||||
@ColumnInfo(name = "baseUrl") val baseUrl: String,
|
|
||||||
@ColumnInfo(name = "topic") val topic: String,
|
|
||||||
@ColumnInfo(name = "mutedUntil") val mutedUntil: Long,
|
|
||||||
@ColumnInfo(name = "minPriority") val minPriority: Int,
|
|
||||||
@ColumnInfo(name = "autoDelete") val autoDelete: Long, // Seconds
|
|
||||||
@ColumnInfo(name = "insistent") val insistent: Int, // Ring constantly for max priority notifications (-1 = use global, 0 = off, 1 = on)
|
|
||||||
@ColumnInfo(name = "upAppId") val upAppId: String?, // UnifiedPush application package name
|
|
||||||
@ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token
|
|
||||||
@ColumnInfo(name = "displayName") val displayName: String?,
|
|
||||||
@Ignore val totalCount: Int = 0, // Total notifications
|
|
||||||
@Ignore val newCount: Int = 0, // New notifications
|
|
||||||
@Ignore val lastActive: Long = 0, // Unix timestamp
|
|
||||||
@Ignore val connectionDetails: ConnectionDetails = ConnectionDetails()
|
|
||||||
) {
|
|
||||||
constructor(
|
|
||||||
id: Long,
|
|
||||||
baseUrl: String,
|
|
||||||
topic: String,
|
|
||||||
mutedUntil: Long,
|
|
||||||
minPriority: Int,
|
|
||||||
autoDelete: Long,
|
|
||||||
insistent: Int,
|
|
||||||
upAppId: String?,
|
|
||||||
upConnectorToken: String?,
|
|
||||||
displayName: String?
|
|
||||||
) :
|
|
||||||
this(
|
|
||||||
id,
|
|
||||||
baseUrl,
|
|
||||||
topic,
|
|
||||||
mutedUntil,
|
|
||||||
minPriority,
|
|
||||||
autoDelete,
|
|
||||||
insistent,
|
|
||||||
upAppId,
|
|
||||||
upConnectorToken,
|
|
||||||
displayName,
|
|
||||||
totalCount = 0,
|
|
||||||
newCount = 0,
|
|
||||||
lastActive = 0,
|
|
||||||
connectionDetails = ConnectionDetails()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class ConnectionState {
|
|
||||||
NOT_APPLICABLE, CONNECTING, CONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ConnectionDetails(
|
|
||||||
val state: ConnectionState = ConnectionState.NOT_APPLICABLE,
|
|
||||||
val error: Throwable? = null,
|
|
||||||
val nextRetryTime: Long = 0L
|
|
||||||
) {
|
|
||||||
fun getStackTraceString(): String {
|
|
||||||
return error?.stackTraceToString() ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasError(): Boolean {
|
|
||||||
return error != null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isConnectionRefused(): Boolean {
|
|
||||||
return error?.hasCause(ConnectException::class.java) ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isNotAuthorized(): Boolean {
|
|
||||||
return error?.hasCause(NotAuthorizedException::class.java) ?: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SubscriptionWithMetadata(
|
|
||||||
val id: Long,
|
|
||||||
val baseUrl: String,
|
|
||||||
val topic: String,
|
|
||||||
val mutedUntil: Long,
|
|
||||||
val autoDelete: Long,
|
|
||||||
val minPriority: Int,
|
|
||||||
val insistent: Int,
|
|
||||||
val upAppId: String?,
|
|
||||||
val upConnectorToken: String?,
|
|
||||||
val displayName: String?,
|
|
||||||
val totalCount: Int,
|
|
||||||
val newCount: Int,
|
|
||||||
val lastActive: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
@Entity(primaryKeys = ["id", "subscriptionId"])
|
|
||||||
data class Notification(
|
|
||||||
@ColumnInfo(name = "id") val id: String,
|
|
||||||
@ColumnInfo(name = "subscriptionId") val subscriptionId: Long,
|
|
||||||
@ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp
|
|
||||||
@ColumnInfo(name = "title") val title: String,
|
|
||||||
@ColumnInfo(name = "message") val message: String,
|
|
||||||
@ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID
|
|
||||||
@ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max
|
|
||||||
@ColumnInfo(name = "tags") val tags: String,
|
|
||||||
@ColumnInfo(name = "deleted") val deleted: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Entity(tableName = "Log")
|
|
||||||
data class LogEntry(
|
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long,
|
|
||||||
@ColumnInfo(name = "timestamp") val timestamp: Long,
|
|
||||||
@ColumnInfo(name = "tag") val tag: String,
|
|
||||||
@ColumnInfo(name = "level") val level: Int,
|
|
||||||
@ColumnInfo(name = "message") val message: String,
|
|
||||||
@ColumnInfo(name = "exception") val exception: String?
|
|
||||||
) {
|
|
||||||
@Ignore constructor(timestamp: Long, tag: String, level: Int, message: String, exception: String?) :
|
|
||||||
this(0, timestamp, tag, level, message, exception)
|
|
||||||
}
|
|
||||||
|
|
||||||
@androidx.room.Database(
|
|
||||||
version = 17,
|
|
||||||
entities = [
|
|
||||||
Subscription::class,
|
|
||||||
Notification::class,
|
|
||||||
LogEntry::class
|
|
||||||
]
|
|
||||||
)
|
|
||||||
abstract class Database : RoomDatabase() {
|
|
||||||
abstract fun subscriptionDao(): SubscriptionDao
|
|
||||||
abstract fun notificationDao(): NotificationDao
|
|
||||||
abstract fun logDao(): LogDao
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
@Volatile
|
|
||||||
private var instance: Database? = null
|
|
||||||
|
|
||||||
fun getInstance(context: Context): Database {
|
|
||||||
return instance ?: synchronized(this) {
|
|
||||||
val instance = Room
|
|
||||||
.databaseBuilder(context.applicationContext, Database::class.java, "AppDatabase")
|
|
||||||
.addMigrations(MIGRATION_1_2)
|
|
||||||
.addMigrations(MIGRATION_2_3)
|
|
||||||
.addMigrations(MIGRATION_3_4)
|
|
||||||
.addMigrations(MIGRATION_4_5)
|
|
||||||
.addMigrations(MIGRATION_5_6)
|
|
||||||
.addMigrations(MIGRATION_6_7)
|
|
||||||
.addMigrations(MIGRATION_7_8)
|
|
||||||
.addMigrations(MIGRATION_8_9)
|
|
||||||
.addMigrations(MIGRATION_9_10)
|
|
||||||
.addMigrations(MIGRATION_10_11)
|
|
||||||
.addMigrations(MIGRATION_11_12)
|
|
||||||
.addMigrations(MIGRATION_12_13)
|
|
||||||
.addMigrations(MIGRATION_13_14)
|
|
||||||
.addMigrations(MIGRATION_14_15)
|
|
||||||
.addMigrations(MIGRATION_15_16)
|
|
||||||
.addMigrations(MIGRATION_16_17)
|
|
||||||
.fallbackToDestructiveMigration(true)
|
|
||||||
.build()
|
|
||||||
this.instance = instance
|
|
||||||
instance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("CREATE TABLE Subscription_New (id INTEGER NOT NULL, baseUrl TEXT NOT NULL, topic TEXT NOT NULL, instant INTEGER NOT NULL DEFAULT('0'), PRIMARY KEY(id))")
|
|
||||||
db.execSQL("INSERT INTO Subscription_New SELECT id, baseUrl, topic, 0 FROM Subscription")
|
|
||||||
db.execSQL("DROP TABLE Subscription")
|
|
||||||
db.execSQL("ALTER TABLE Subscription_New RENAME TO Subscription")
|
|
||||||
db.execSQL("CREATE UNIQUE INDEX index_Subscription_baseUrl_topic ON Subscription (baseUrl, topic)")
|
|
||||||
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN notificationId INTEGER NOT NULL DEFAULT('0')")
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN deleted INTEGER NOT NULL DEFAULT('0')")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN mutedUntil INTEGER NOT NULL DEFAULT('0')")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_3_4 = object : Migration(3, 4) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("CREATE TABLE Notification_New (id TEXT NOT NULL, subscriptionId INTEGER NOT NULL, timestamp INTEGER NOT NULL, title TEXT NOT NULL, message TEXT NOT NULL, notificationId INTEGER NOT NULL, priority INTEGER NOT NULL DEFAULT(3), tags TEXT NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(id, subscriptionId))")
|
|
||||||
db.execSQL("INSERT INTO Notification_New SELECT id, subscriptionId, timestamp, '', message, notificationId, 3, '', deleted FROM Notification")
|
|
||||||
db.execSQL("DROP TABLE Notification")
|
|
||||||
db.execSQL("ALTER TABLE Notification_New RENAME TO Notification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT")
|
|
||||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT")
|
|
||||||
db.execSQL("CREATE UNIQUE INDEX index_Subscription_upConnectorToken ON Subscription (upConnectorToken)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_5_6 = object : Migration(5, 6) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN click TEXT NOT NULL DEFAULT('')")
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_name TEXT")
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_type TEXT")
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_size INT")
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_expires INT")
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_url TEXT")
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_contentUri TEXT")
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_progress INT")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_6_7 = object : Migration(6, 7) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("CREATE TABLE Log (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp INT NOT NULL, tag TEXT NOT NULL, level INT NOT NULL, message TEXT NOT NULL, exception TEXT)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_7_8 = object : Migration(7, 8) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("CREATE TABLE User (baseUrl TEXT NOT NULL, username TEXT NOT NULL, password TEXT NOT NULL, PRIMARY KEY(baseUrl))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_8_9 = object : Migration(8, 9) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN encoding TEXT NOT NULL DEFAULT('')")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_9_10 = object : Migration(9, 10) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN actions TEXT")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_10_11 = object : Migration(10, 11) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN minPriority INT NOT NULL DEFAULT (0)")
|
|
||||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN autoDelete INT NOT NULL DEFAULT (-1)")
|
|
||||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN icon TEXT")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_11_12 = object : Migration(11, 12) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN lastNotificationId TEXT")
|
|
||||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN displayName TEXT")
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN icon_url TEXT")
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN icon_contentUri TEXT")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_12_13 = object : Migration(12, 13) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN insistent INTEGER NOT NULL DEFAULT (-1)")
|
|
||||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN dedicatedChannels INTEGER NOT NULL DEFAULT (0)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_13_14 = object : Migration(13, 14) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE Notification ADD COLUMN contentType TEXT NOT NULL DEFAULT ('')")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_14_15 = object : Migration(14, 15) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("CREATE TABLE CustomHeader (baseUrl TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(baseUrl, name))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_15_16 = object : Migration(15, 16) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("CREATE TABLE TrustedCertificate (baseUrl TEXT NOT NULL, pem TEXT NOT NULL, PRIMARY KEY(baseUrl))")
|
|
||||||
db.execSQL("CREATE TABLE ClientCertificate (baseUrl TEXT NOT NULL, p12Base64 TEXT NOT NULL, password TEXT NOT NULL, PRIMARY KEY(baseUrl))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val MIGRATION_16_17 = object : Migration(16, 17) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("UPDATE Notification SET icon_contentUri = NULL WHERE icon_url IS NULL AND icon_contentUri IS NOT NULL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface SubscriptionDao {
|
|
||||||
@Query("""
|
|
||||||
SELECT
|
|
||||||
s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName,
|
|
||||||
COUNT(n.id) totalCount,
|
|
||||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
|
||||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
|
||||||
FROM Subscription AS s
|
|
||||||
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
|
|
||||||
GROUP BY s.id
|
|
||||||
ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC
|
|
||||||
""")
|
|
||||||
fun listFlow(): Flow<List<SubscriptionWithMetadata>>
|
|
||||||
|
|
||||||
@Query("""
|
|
||||||
SELECT
|
|
||||||
s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName,
|
|
||||||
COUNT(n.id) totalCount,
|
|
||||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
|
||||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
|
||||||
FROM Subscription AS s
|
|
||||||
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
|
|
||||||
GROUP BY s.id
|
|
||||||
ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC
|
|
||||||
""")
|
|
||||||
suspend fun list(): List<SubscriptionWithMetadata>
|
|
||||||
|
|
||||||
@Query("""
|
|
||||||
SELECT
|
|
||||||
s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName,
|
|
||||||
COUNT(n.id) totalCount,
|
|
||||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
|
||||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
|
||||||
FROM Subscription AS s
|
|
||||||
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
|
|
||||||
WHERE s.baseUrl = :baseUrl AND s.topic = :topic
|
|
||||||
GROUP BY s.id
|
|
||||||
""")
|
|
||||||
fun get(baseUrl: String, topic: String): SubscriptionWithMetadata?
|
|
||||||
|
|
||||||
@Query("""
|
|
||||||
SELECT
|
|
||||||
s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName,
|
|
||||||
COUNT(n.id) totalCount,
|
|
||||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
|
||||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
|
||||||
FROM Subscription AS s
|
|
||||||
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
|
|
||||||
WHERE s.id = :subscriptionId
|
|
||||||
GROUP BY s.id
|
|
||||||
""")
|
|
||||||
fun get(subscriptionId: Long): SubscriptionWithMetadata?
|
|
||||||
|
|
||||||
@Query("""
|
|
||||||
SELECT
|
|
||||||
s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName,
|
|
||||||
COUNT(n.id) totalCount,
|
|
||||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
|
||||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
|
||||||
FROM Subscription AS s
|
|
||||||
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
|
|
||||||
WHERE s.upConnectorToken = :connectorToken
|
|
||||||
GROUP BY s.id
|
|
||||||
""")
|
|
||||||
fun getByConnectorToken(connectorToken: String): SubscriptionWithMetadata?
|
|
||||||
|
|
||||||
@Query("""
|
|
||||||
SELECT
|
|
||||||
s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName,
|
|
||||||
COUNT(n.id) totalCount,
|
|
||||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
|
||||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
|
||||||
FROM Subscription AS s
|
|
||||||
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
|
|
||||||
WHERE s.upAppId = :upAppId
|
|
||||||
GROUP BY s.id
|
|
||||||
""")
|
|
||||||
fun getByUpAppId(upAppId: String): SubscriptionWithMetadata?
|
|
||||||
|
|
||||||
@Insert
|
|
||||||
fun add(subscription: Subscription)
|
|
||||||
|
|
||||||
@Update
|
|
||||||
fun update(subscription: Subscription)
|
|
||||||
|
|
||||||
@Query("DELETE FROM subscription WHERE id = :subscriptionId")
|
|
||||||
fun remove(subscriptionId: Long)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface NotificationDao {
|
|
||||||
@Query("SELECT * FROM notification")
|
|
||||||
suspend fun list(): List<Notification>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC")
|
|
||||||
fun listFlow(subscriptionId: Long): Flow<List<Notification>>
|
|
||||||
|
|
||||||
@Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId")
|
|
||||||
fun listIds(subscriptionId: Long): List<String>
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
|
||||||
fun add(notification: Notification)
|
|
||||||
|
|
||||||
@Update(onConflict = OnConflictStrategy.IGNORE)
|
|
||||||
fun update(notification: Notification)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM notification WHERE id = :notificationId")
|
|
||||||
fun get(notificationId: String): Notification?
|
|
||||||
|
|
||||||
@Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId")
|
|
||||||
fun clearAllNotificationIds(subscriptionId: Long)
|
|
||||||
|
|
||||||
@Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId")
|
|
||||||
fun markAsDeleted(notificationId: String)
|
|
||||||
|
|
||||||
@Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId")
|
|
||||||
fun markAllAsDeleted(subscriptionId: Long)
|
|
||||||
|
|
||||||
@Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId AND timestamp < :olderThanTimestamp")
|
|
||||||
fun markAsDeletedIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long)
|
|
||||||
|
|
||||||
@Query("UPDATE notification SET deleted = 0 WHERE id = :notificationId")
|
|
||||||
fun undelete(notificationId: String)
|
|
||||||
|
|
||||||
@Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId AND timestamp < :olderThanTimestamp")
|
|
||||||
fun removeIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long)
|
|
||||||
|
|
||||||
@Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId")
|
|
||||||
fun removeAll(subscriptionId: Long)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface LogDao {
|
|
||||||
@Insert
|
|
||||||
suspend fun insert(entry: LogEntry)
|
|
||||||
|
|
||||||
@Query("DELETE FROM log WHERE id NOT IN (SELECT id FROM log ORDER BY timestamp DESC, id DESC LIMIT :keepCount)")
|
|
||||||
suspend fun prune(keepCount: Int)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM log ORDER BY timestamp ASC, id ASC")
|
|
||||||
fun getAll(): List<LogEntry>
|
|
||||||
|
|
||||||
@Query("DELETE FROM log")
|
|
||||||
fun deleteAll()
|
|
||||||
}
|
|
||||||
|
|
@ -1,493 +0,0 @@
|
||||||
package com.lonecloud.sup.db
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.media.MediaPlayer
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MediatorLiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.asLiveData
|
|
||||||
import androidx.lifecycle.map
|
|
||||||
import com.lonecloud.sup.util.Log
|
|
||||||
import com.lonecloud.sup.util.validUrl
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
|
|
||||||
class Repository(private val sharedPrefs: SharedPreferences, database: Database) {
|
|
||||||
private val subscriptionDao = database.subscriptionDao()
|
|
||||||
private val notificationDao = database.notificationDao()
|
|
||||||
|
|
||||||
private val connectionDetails = ConcurrentHashMap<String, ConnectionDetails>()
|
|
||||||
private val connectionDetailsLiveData = MutableLiveData<Map<String, ConnectionDetails>>(connectionDetails)
|
|
||||||
private val connectionForceReconnectVersions = ConcurrentHashMap<String, Long>()
|
|
||||||
|
|
||||||
// TODO Move these into an ApplicationState singleton
|
|
||||||
val detailViewSubscriptionId = AtomicLong(0L) // Omg, what a hack ...
|
|
||||||
val mediaPlayer = MediaPlayer()
|
|
||||||
|
|
||||||
init {
|
|
||||||
Log.d(TAG, "Created $this")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSubscriptionsLiveData(): LiveData<List<Subscription>> {
|
|
||||||
return subscriptionDao
|
|
||||||
.listFlow()
|
|
||||||
.asLiveData()
|
|
||||||
.combineWith(connectionDetailsLiveData) { subscriptionsWithMetadata, _ ->
|
|
||||||
toSubscriptionList(subscriptionsWithMetadata.orEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSubscriptionIdsWithInstantStatusLiveData(): LiveData<Set<Pair<Long, Boolean>>> {
|
|
||||||
return subscriptionDao
|
|
||||||
.listFlow()
|
|
||||||
.asLiveData()
|
|
||||||
.map { list -> list.map { Pair(it.id, false) }.toSet() }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getSubscriptions(): List<Subscription> {
|
|
||||||
return toSubscriptionList(subscriptionDao.list())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSubscription(subscriptionId: Long): Subscription? {
|
|
||||||
return toSubscription(subscriptionDao.get(subscriptionId))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
|
||||||
@WorkerThread
|
|
||||||
suspend fun getSubscription(baseUrl: String, topic: String): Subscription? {
|
|
||||||
return toSubscription(subscriptionDao.get(baseUrl, topic))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
|
||||||
@WorkerThread
|
|
||||||
suspend fun getSubscriptionByConnectorToken(connectorToken: String): Subscription? {
|
|
||||||
return toSubscription(subscriptionDao.getByConnectorToken(connectorToken))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
|
||||||
@WorkerThread
|
|
||||||
suspend fun addSubscription(subscription: Subscription) {
|
|
||||||
subscriptionDao.add(subscription)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
|
||||||
@WorkerThread
|
|
||||||
suspend fun updateSubscription(subscription: Subscription) {
|
|
||||||
subscriptionDao.update(subscription)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
|
||||||
@WorkerThread
|
|
||||||
suspend fun removeSubscription(subscriptionId: Long) {
|
|
||||||
subscriptionDao.remove(subscriptionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getNotifications(): List<Notification> {
|
|
||||||
return notificationDao.list()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> {
|
|
||||||
return notificationDao.listFlow(subscriptionId).asLiveData()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearAllNotificationIds(subscriptionId: Long) {
|
|
||||||
return notificationDao.clearAllNotificationIds(subscriptionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getNotification(notificationId: String): Notification? {
|
|
||||||
return notificationDao.get(notificationId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onlyNewNotifications(subscriptionId: Long, notifications: List<Notification>): List<Notification> {
|
|
||||||
val existingIds = notificationDao.listIds(subscriptionId)
|
|
||||||
return notifications.filterNot { existingIds.contains(it.id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("RedundantSuspendModifier")
|
|
||||||
@WorkerThread
|
|
||||||
suspend fun addNotification(notification: Notification): Boolean {
|
|
||||||
val maybeExistingNotification = notificationDao.get(notification.id)
|
|
||||||
if (maybeExistingNotification != null) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
notificationDao.add(notification)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateNotification(notification: Notification) {
|
|
||||||
notificationDao.update(notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun undeleteNotification(notificationId: String) {
|
|
||||||
notificationDao.undelete(notificationId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun markAsDeleted(notificationId: String) {
|
|
||||||
notificationDao.markAsDeleted(notificationId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun markAllAsDeleted(subscriptionId: Long) {
|
|
||||||
notificationDao.markAllAsDeleted(subscriptionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun markAsDeletedIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long) {
|
|
||||||
notificationDao.markAsDeletedIfOlderThan(subscriptionId, olderThanTimestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeNotificationsIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long) {
|
|
||||||
notificationDao.removeIfOlderThan(subscriptionId, olderThanTimestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeAllNotifications(subscriptionId: Long) {
|
|
||||||
notificationDao.removeAll(subscriptionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMinPriority(minPriority: Int) {
|
|
||||||
if (minPriority <= MIN_PRIORITY_ANY) {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
remove(SHARED_PREFS_MIN_PRIORITY)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putInt(SHARED_PREFS_MIN_PRIORITY, minPriority)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getMinPriority(): Int {
|
|
||||||
return sharedPrefs.getInt(SHARED_PREFS_MIN_PRIORITY, MIN_PRIORITY_ANY)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAutoDownloadMaxSize(): Long {
|
|
||||||
val defaultValue = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
|
||||||
AUTO_DOWNLOAD_NEVER // Need to request permission on older versions
|
|
||||||
} else {
|
|
||||||
AUTO_DOWNLOAD_DEFAULT
|
|
||||||
}
|
|
||||||
return sharedPrefs.getLong(SHARED_PREFS_AUTO_DOWNLOAD_MAX_SIZE, defaultValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAutoDownloadMaxSize(maxSize: Long) {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putLong(SHARED_PREFS_AUTO_DOWNLOAD_MAX_SIZE, maxSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAutoDeleteSeconds(): Long {
|
|
||||||
return sharedPrefs.getLong(SHARED_PREFS_AUTO_DELETE_SECONDS, AUTO_DELETE_DEFAULT_SECONDS)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAutoDeleteSeconds(seconds: Long) {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putLong(SHARED_PREFS_AUTO_DELETE_SECONDS, seconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDarkMode(mode: Int) {
|
|
||||||
if (mode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
remove(SHARED_PREFS_DARK_MODE)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putInt(SHARED_PREFS_DARK_MODE, mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDarkMode(): Int {
|
|
||||||
return sharedPrefs.getInt(SHARED_PREFS_DARK_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDynamicColorsEnabled(enabled: Boolean) {
|
|
||||||
sharedPrefs.edit(commit = true) {
|
|
||||||
putBoolean(SHARED_PREFS_DYNAMIC_COLORS, enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDynamicColorsEnabled(): Boolean {
|
|
||||||
return sharedPrefs.getBoolean(SHARED_PREFS_DYNAMIC_COLORS, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getBroadcastEnabled(): Boolean {
|
|
||||||
return sharedPrefs.getBoolean(SHARED_PREFS_BROADCAST_ENABLED, true) // Enabled by default
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setBroadcastEnabled(enabled: Boolean) {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putBoolean(SHARED_PREFS_BROADCAST_ENABLED, enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getUnifiedPushEnabled(): Boolean {
|
|
||||||
return sharedPrefs.getBoolean(SHARED_PREFS_UNIFIEDPUSH_ENABLED, true) // Enabled by default
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setUnifiedPushEnabled(enabled: Boolean) {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putBoolean(SHARED_PREFS_UNIFIEDPUSH_ENABLED, enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getInsistentMaxPriorityEnabled(): Boolean {
|
|
||||||
return sharedPrefs.getBoolean(SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED, false) // Disabled by default
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setInsistentMaxPriorityEnabled(enabled: Boolean) {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putBoolean(SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED, enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getRecordLogs(): Boolean {
|
|
||||||
return sharedPrefs.getBoolean(SHARED_PREFS_RECORD_LOGS_ENABLED, false) // Disabled by default
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setRecordLogsEnabled(enabled: Boolean) {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putBoolean(SHARED_PREFS_RECORD_LOGS_ENABLED, enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getMessageBarEnabled(): Boolean {
|
|
||||||
return sharedPrefs.getBoolean(SHARED_PREFS_MESSAGE_BAR_ENABLED, true) // Enabled by default
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMessageBarEnabled(enabled: Boolean) {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putBoolean(SHARED_PREFS_MESSAGE_BAR_ENABLED, enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getBatteryOptimizationsRemindTime(): Long {
|
|
||||||
return sharedPrefs.getLong(SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME, BATTERY_OPTIMIZATIONS_REMIND_TIME_ALWAYS)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setBatteryOptimizationsRemindTime(timeMillis: Long) {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putLong(SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME, timeMillis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDefaultBaseUrl(): String? {
|
|
||||||
return sharedPrefs.getString(SHARED_PREFS_DEFAULT_BASE_URL, null) ?:
|
|
||||||
sharedPrefs.getString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, null) // Fall back to UP URL, removed when default is set!
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDefaultBaseUrl(baseUrl: String) {
|
|
||||||
if (baseUrl == "") {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL) // Remove legacy key
|
|
||||||
.remove(SHARED_PREFS_DEFAULT_BASE_URL)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL) // Remove legacy key
|
|
||||||
.putString(SHARED_PREFS_DEFAULT_BASE_URL, baseUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isGlobalMuted(): Boolean {
|
|
||||||
val mutedUntil = getGlobalMutedUntil()
|
|
||||||
return mutedUntil == 1L || (mutedUntil > 1L && mutedUntil > System.currentTimeMillis()/1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getGlobalMutedUntil(): Long {
|
|
||||||
return sharedPrefs.getLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setGlobalMutedUntil(mutedUntilTimestamp: Long) {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, mutedUntilTimestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkGlobalMutedUntil(): Boolean {
|
|
||||||
val mutedUntil = sharedPrefs.getLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L)
|
|
||||||
val expired = mutedUntil > 1L && System.currentTimeMillis()/1000 > mutedUntil
|
|
||||||
if (expired) {
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLastShareTopics(): List<String> {
|
|
||||||
val topics = sharedPrefs.getString(SHARED_PREFS_LAST_TOPICS, "") ?: ""
|
|
||||||
return topics.split("\n").filter { validUrl(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addLastShareTopic(topic: String) {
|
|
||||||
val topics = (getLastShareTopics().filterNot { it == topic } + topic).takeLast(LAST_TOPICS_COUNT)
|
|
||||||
sharedPrefs.edit {
|
|
||||||
putString(SHARED_PREFS_LAST_TOPICS, topics.joinToString(separator = "\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toSubscriptionList(list: List<SubscriptionWithMetadata>): List<Subscription> {
|
|
||||||
return list.map { s ->
|
|
||||||
Subscription(
|
|
||||||
id = s.id,
|
|
||||||
baseUrl = s.baseUrl,
|
|
||||||
topic = s.topic,
|
|
||||||
mutedUntil = s.mutedUntil,
|
|
||||||
minPriority = s.minPriority,
|
|
||||||
autoDelete = s.autoDelete,
|
|
||||||
insistent = s.insistent,
|
|
||||||
upAppId = s.upAppId,
|
|
||||||
upConnectorToken = s.upConnectorToken,
|
|
||||||
displayName = s.displayName,
|
|
||||||
totalCount = s.totalCount,
|
|
||||||
newCount = s.newCount,
|
|
||||||
lastActive = s.lastActive,
|
|
||||||
connectionDetails = connectionDetails[s.baseUrl] ?: ConnectionDetails()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toSubscription(s: SubscriptionWithMetadata?): Subscription? {
|
|
||||||
if (s == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return Subscription(
|
|
||||||
id = s.id,
|
|
||||||
baseUrl = s.baseUrl,
|
|
||||||
topic = s.topic,
|
|
||||||
mutedUntil = s.mutedUntil,
|
|
||||||
minPriority = s.minPriority,
|
|
||||||
autoDelete = s.autoDelete,
|
|
||||||
insistent = s.insistent,
|
|
||||||
upAppId = s.upAppId,
|
|
||||||
upConnectorToken = s.upConnectorToken,
|
|
||||||
displayName = s.displayName,
|
|
||||||
totalCount = s.totalCount,
|
|
||||||
newCount = s.newCount,
|
|
||||||
lastActive = s.lastActive,
|
|
||||||
connectionDetails = connectionDetails[s.baseUrl] ?: ConnectionDetails()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateConnectionDetails(baseUrl: String, state: ConnectionState, error: Throwable? = null, nextRetryTime: Long = 0L) {
|
|
||||||
val details = ConnectionDetails(state, error, nextRetryTime)
|
|
||||||
val current = connectionDetails[baseUrl]
|
|
||||||
if (current != details) {
|
|
||||||
if (state == ConnectionState.NOT_APPLICABLE && error == null) {
|
|
||||||
connectionDetails.remove(baseUrl)
|
|
||||||
} else {
|
|
||||||
connectionDetails[baseUrl] = details
|
|
||||||
}
|
|
||||||
connectionDetailsLiveData.postValue(connectionDetails.toMap())
|
|
||||||
Log.d(TAG, "Connection details updated for $baseUrl: state=$state, error=${error?.message}, nextRetry=$nextRetryTime")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getConnectionDetailsLiveData(): LiveData<Map<String, ConnectionDetails>> {
|
|
||||||
return connectionDetailsLiveData
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getConnectionDetails(): Map<String, ConnectionDetails> {
|
|
||||||
return connectionDetails.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getConnectionDetailsForBaseUrl(baseUrl: String): ConnectionDetails? {
|
|
||||||
return connectionDetails[baseUrl]
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getConnectionForceReconnectVersion(baseUrl: String): Long {
|
|
||||||
return connectionForceReconnectVersions[baseUrl] ?: 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
fun incrementConnectionForceReconnectVersion(baseUrl: String) {
|
|
||||||
connectionForceReconnectVersions.compute(baseUrl) { _, current -> (current ?: 0L) + 1 }
|
|
||||||
Log.d(TAG, "Connection force reconnect version incremented for $baseUrl: ${connectionForceReconnectVersions[baseUrl]}")
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val SHARED_PREFS_ID = "MainPreferences"
|
|
||||||
const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil"
|
|
||||||
const val SHARED_PREFS_MIN_PRIORITY = "MinPriority"
|
|
||||||
const val SHARED_PREFS_AUTO_DOWNLOAD_MAX_SIZE = "AutoDownload"
|
|
||||||
const val SHARED_PREFS_AUTO_DELETE_SECONDS = "AutoDelete"
|
|
||||||
const val SHARED_PREFS_DARK_MODE = "DarkMode"
|
|
||||||
const val SHARED_PREFS_DYNAMIC_COLORS = "DynamicColors"
|
|
||||||
const val SHARED_PREFS_BROADCAST_ENABLED = "BroadcastEnabled"
|
|
||||||
const val SHARED_PREFS_UNIFIEDPUSH_ENABLED = "UnifiedPushEnabled"
|
|
||||||
const val SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED = "InsistentMaxPriority"
|
|
||||||
const val SHARED_PREFS_RECORD_LOGS_ENABLED = "RecordLogs"
|
|
||||||
const val SHARED_PREFS_MESSAGE_BAR_ENABLED = "MessageBarEnabled"
|
|
||||||
const val SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME = "BatteryOptimizationsRemindTime"
|
|
||||||
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL" // Legacy key required for migration to DefaultBaseURL
|
|
||||||
const val SHARED_PREFS_DEFAULT_BASE_URL = "DefaultBaseURL"
|
|
||||||
const val SHARED_PREFS_LAST_TOPICS = "LastTopics"
|
|
||||||
|
|
||||||
private const val LAST_TOPICS_COUNT = 3
|
|
||||||
|
|
||||||
const val MIN_PRIORITY_USE_GLOBAL = 0
|
|
||||||
const val MIN_PRIORITY_ANY = 1
|
|
||||||
|
|
||||||
const val MUTED_UNTIL_SHOW_ALL = 0L
|
|
||||||
const val MUTED_UNTIL_FOREVER = 1L
|
|
||||||
const val MUTED_UNTIL_TOMORROW = 2L
|
|
||||||
|
|
||||||
private const val ONE_MB = 1024 * 1024L
|
|
||||||
const val AUTO_DOWNLOAD_NEVER = 0L // Values must match values.xml
|
|
||||||
const val AUTO_DOWNLOAD_ALWAYS = 1L
|
|
||||||
const val AUTO_DOWNLOAD_DEFAULT = ONE_MB
|
|
||||||
|
|
||||||
private const val ONE_DAY_SECONDS = 24 * 60 * 60L
|
|
||||||
const val AUTO_DELETE_USE_GLOBAL = -1L // Values must match values.xml
|
|
||||||
const val AUTO_DELETE_NEVER = 0L
|
|
||||||
const val AUTO_DELETE_ONE_DAY_SECONDS = ONE_DAY_SECONDS
|
|
||||||
const val AUTO_DELETE_THREE_DAYS_SECONDS = 3 * ONE_DAY_SECONDS
|
|
||||||
const val AUTO_DELETE_ONE_WEEK_SECONDS = 7 * ONE_DAY_SECONDS
|
|
||||||
const val AUTO_DELETE_ONE_MONTH_SECONDS = 30 * ONE_DAY_SECONDS
|
|
||||||
const val AUTO_DELETE_THREE_MONTHS_SECONDS = 90 * ONE_DAY_SECONDS
|
|
||||||
const val AUTO_DELETE_DEFAULT_SECONDS = AUTO_DELETE_ONE_MONTH_SECONDS
|
|
||||||
|
|
||||||
const val INSISTENT_MAX_PRIORITY_USE_GLOBAL = -1 // Values must match values.xml
|
|
||||||
const val INSISTENT_MAX_PRIORITY_ENABLED = 1 // 0 = Disabled (but not needed in code)
|
|
||||||
|
|
||||||
const val BATTERY_OPTIMIZATIONS_REMIND_TIME_ALWAYS = 1L
|
|
||||||
const val BATTERY_OPTIMIZATIONS_REMIND_TIME_NEVER = Long.MAX_VALUE
|
|
||||||
|
|
||||||
private const val TAG = "NtfyRepository"
|
|
||||||
private var instance: Repository? = null
|
|
||||||
|
|
||||||
fun getInstance(context: Context): Repository {
|
|
||||||
val database = Database.getInstance(context.applicationContext)
|
|
||||||
val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
|
||||||
return getInstance(sharedPrefs, database)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getInstance(sharedPrefs: SharedPreferences, database: Database): Repository {
|
|
||||||
return synchronized(Repository::class) {
|
|
||||||
val newInstance = instance ?: Repository(sharedPrefs, database)
|
|
||||||
instance = newInstance
|
|
||||||
newInstance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* https://stackoverflow.com/a/57079290/1440785 */
|
|
||||||
fun <T, K, R> LiveData<T>.combineWith(
|
|
||||||
liveData: LiveData<K>,
|
|
||||||
block: (T?, K?) -> R
|
|
||||||
): LiveData<R> {
|
|
||||||
val result = MediatorLiveData<R>()
|
|
||||||
result.addSource(this) {
|
|
||||||
result.value = block(this.value, liveData.value)
|
|
||||||
}
|
|
||||||
result.addSource(liveData) {
|
|
||||||
result.value = block(this.value, liveData.value)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
package com.lonecloud.sup.msg
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.lonecloud.sup.db.Notification
|
|
||||||
import com.lonecloud.sup.db.Repository
|
|
||||||
import com.lonecloud.sup.service.NotAuthorizedException
|
|
||||||
import com.lonecloud.sup.util.ALL_PRIORITIES
|
|
||||||
import com.lonecloud.sup.util.HttpUtil
|
|
||||||
import com.lonecloud.sup.util.Log
|
|
||||||
import com.lonecloud.sup.util.PRIORITY_DEFAULT
|
|
||||||
import com.lonecloud.sup.util.topicUrl
|
|
||||||
import com.lonecloud.sup.util.topicUrlAuth
|
|
||||||
import com.lonecloud.sup.util.topicUrlJson
|
|
||||||
import com.lonecloud.sup.util.topicUrlJsonPoll
|
|
||||||
import okhttp3.Call
|
|
||||||
import okhttp3.RequestBody
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okio.BufferedSource
|
|
||||||
import java.io.IOException
|
|
||||||
import java.net.URLEncoder
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
class ApiService(private val context: Context) {
|
|
||||||
private val repository = Repository.getInstance(context)
|
|
||||||
private val gson = Gson()
|
|
||||||
private val parser = NotificationParser()
|
|
||||||
|
|
||||||
suspend fun publish(
|
|
||||||
baseUrl: String,
|
|
||||||
topic: String,
|
|
||||||
message: String,
|
|
||||||
title: String = "",
|
|
||||||
priority: Int = PRIORITY_DEFAULT,
|
|
||||||
tags: List<String> = emptyList(),
|
|
||||||
delay: String = "",
|
|
||||||
body: RequestBody? = null,
|
|
||||||
filename: String = "",
|
|
||||||
click: String = "",
|
|
||||||
attach: String = "",
|
|
||||||
email: String = "",
|
|
||||||
call: String = "",
|
|
||||||
markdown: Boolean = false,
|
|
||||||
onCancelAvailable: ((cancel: () -> Unit) -> Unit)? = null // Called when the HTTP request was started and cancellable (caller can cancel)
|
|
||||||
) {
|
|
||||||
val url = topicUrl(baseUrl, topic)
|
|
||||||
val query = mutableListOf<String>()
|
|
||||||
if (priority in ALL_PRIORITIES) {
|
|
||||||
query.add("priority=$priority")
|
|
||||||
}
|
|
||||||
if (tags.isNotEmpty()) {
|
|
||||||
query.add("tags=${URLEncoder.encode(tags.joinToString(","), "UTF-8")}")
|
|
||||||
}
|
|
||||||
if (title.isNotEmpty()) {
|
|
||||||
query.add("title=${URLEncoder.encode(title, "UTF-8")}")
|
|
||||||
}
|
|
||||||
if (delay.isNotEmpty()) {
|
|
||||||
query.add("delay=${URLEncoder.encode(delay, "UTF-8")}")
|
|
||||||
}
|
|
||||||
if (filename.isNotEmpty()) {
|
|
||||||
query.add("filename=${URLEncoder.encode(filename, "UTF-8")}")
|
|
||||||
}
|
|
||||||
if (click.isNotEmpty()) {
|
|
||||||
query.add("click=${URLEncoder.encode(click, "UTF-8")}")
|
|
||||||
}
|
|
||||||
if (attach.isNotEmpty()) {
|
|
||||||
query.add("attach=${URLEncoder.encode(attach, "UTF-8")}")
|
|
||||||
}
|
|
||||||
if (email.isNotEmpty()) {
|
|
||||||
query.add("email=${URLEncoder.encode(email, "UTF-8")}")
|
|
||||||
}
|
|
||||||
if (call.isNotEmpty()) {
|
|
||||||
query.add("call=${URLEncoder.encode(call, "UTF-8")}")
|
|
||||||
}
|
|
||||||
if (markdown) {
|
|
||||||
query.add("markdown=true")
|
|
||||||
}
|
|
||||||
if (body != null) {
|
|
||||||
query.add("message=${URLEncoder.encode(message.replace("\n", "\\n"), "UTF-8")}")
|
|
||||||
}
|
|
||||||
val urlWithQuery = if (query.isNotEmpty()) {
|
|
||||||
url + "?" + query.joinToString("&")
|
|
||||||
} else {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
val request = HttpUtil.requestBuilder(urlWithQuery)
|
|
||||||
.put(body ?: message.toRequestBody())
|
|
||||||
.build()
|
|
||||||
Log.d(TAG, "Publishing to $request")
|
|
||||||
val httpCall = HttpUtil.longCallClient(context, baseUrl).newCall(request)
|
|
||||||
onCancelAvailable?.invoke { httpCall.cancel() } // Notify caller that HTTP request can now be canceled
|
|
||||||
httpCall.execute().use { response ->
|
|
||||||
if (response.code == 401 || response.code == 403) {
|
|
||||||
throw UnauthorizedException()
|
|
||||||
} else if (response.code == 413) {
|
|
||||||
throw EntityTooLargeException()
|
|
||||||
} else if (!response.isSuccessful) {
|
|
||||||
// Try to parse error response from server
|
|
||||||
val errorBody = response.body?.string() ?: ""
|
|
||||||
val apiError = try {
|
|
||||||
gson.fromJson(errorBody, ErrorResponse::class.java)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (apiError?.error != null && apiError.code != null) {
|
|
||||||
throw ApiException(apiError.error, apiError.code)
|
|
||||||
}
|
|
||||||
throw Exception("Unexpected response ${response.code} when publishing to $url")
|
|
||||||
}
|
|
||||||
Log.d(TAG, "Successfully published to $url")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun poll(subscriptionId: Long, baseUrl: String, topic: String, since: String? = null): List<Notification> {
|
|
||||||
val sinceVal = since ?: "all"
|
|
||||||
val url = topicUrlJsonPoll(baseUrl, topic, sinceVal)
|
|
||||||
Log.d(TAG, "Polling topic $url")
|
|
||||||
|
|
||||||
val request = HttpUtil.requestBuilder(url).build()
|
|
||||||
HttpUtil.defaultClient(context, baseUrl).newCall(request).execute().use { response ->
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
throw Exception("Unexpected response ${response.code} when polling topic $url")
|
|
||||||
}
|
|
||||||
val body = response.body?.string()?.trim() ?: ""
|
|
||||||
if (body.isEmpty()) return emptyList()
|
|
||||||
val notifications = body.lines().mapNotNull { line ->
|
|
||||||
parser.parse(line, subscriptionId = subscriptionId, notificationId = 0) // No notification when we poll
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Notifications: $notifications")
|
|
||||||
return notifications
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun subscribe(
|
|
||||||
baseUrl: String,
|
|
||||||
topics: String,
|
|
||||||
since: String?
|
|
||||||
): Pair<Call, BufferedSource> {
|
|
||||||
val sinceVal = since ?: "all"
|
|
||||||
val url = topicUrlJson(baseUrl, topics, sinceVal)
|
|
||||||
Log.d(TAG, "Opening subscription connection to $url")
|
|
||||||
val request = HttpUtil.requestBuilder(url).build()
|
|
||||||
val call = HttpUtil.subscriberClient(context, baseUrl).newCall(request)
|
|
||||||
val response = call.execute()
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
val code = response.code
|
|
||||||
val message = response.message
|
|
||||||
response.close()
|
|
||||||
if (code == 401 || code == 403) {
|
|
||||||
throw NotAuthorizedException("$code $message")
|
|
||||||
}
|
|
||||||
throw IOException("Unexpected response $code when subscribing to $url")
|
|
||||||
}
|
|
||||||
return Pair(call, response.body?.source() ?: throw IOException("Empty response body"))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun checkAuth(baseUrl: String, topic: String): Boolean {
|
|
||||||
Log.d(TAG, "Checking anonymous read against ${topicUrl(baseUrl, topic)}")
|
|
||||||
val url = topicUrlAuth(baseUrl, topic)
|
|
||||||
val request = HttpUtil.requestBuilder(url).build()
|
|
||||||
HttpUtil.defaultClient(context, baseUrl).newCall(request).execute().use { response ->
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
return true
|
|
||||||
} else if (response.code == 404) {
|
|
||||||
return true // Special case: Anonymous login to old servers return 404 since /<topic>/auth doesn't exist
|
|
||||||
} else if (response.code == 401 || response.code == 403) { // See server/server.go
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
throw Exception("Unexpected server response ${response.code}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class UnauthorizedException : Exception()
|
|
||||||
class EntityTooLargeException : Exception()
|
|
||||||
class ApiException(val error: String, val code: Int) : Exception(error)
|
|
||||||
|
|
||||||
private data class ErrorResponse(
|
|
||||||
val code: Int?,
|
|
||||||
val http: Int?,
|
|
||||||
val error: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "NtfyApiService"
|
|
||||||
|
|
||||||
// These constants have corresponding values in the server codebase!
|
|
||||||
const val CONTROL_TOPIC = "~control"
|
|
||||||
const val EVENT_MESSAGE = "message"
|
|
||||||
const val EVENT_KEEPALIVE = "keepalive"
|
|
||||||
const val EVENT_POLL_REQUEST = "poll_request"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
package com.lonecloud.sup.msg
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import com.lonecloud.sup.R
|
|
||||||
import com.lonecloud.sup.db.Notification
|
|
||||||
import com.lonecloud.sup.db.Repository
|
|
||||||
import com.lonecloud.sup.db.Subscription
|
|
||||||
import com.lonecloud.sup.util.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The broadcast service is responsible for sending and receiving broadcast intents
|
|
||||||
* in order to facilitate tasks app integrations.
|
|
||||||
*/
|
|
||||||
class BroadcastService(private val ctx: Context) {
|
|
||||||
fun sendMessage(subscription: Subscription, notification: Notification, muted: Boolean) {
|
|
||||||
val intent = Intent()
|
|
||||||
intent.action = MESSAGE_RECEIVED_ACTION
|
|
||||||
intent.putExtra("id", notification.id)
|
|
||||||
intent.putExtra("base_url", subscription.baseUrl)
|
|
||||||
intent.putExtra("topic", subscription.topic)
|
|
||||||
intent.putExtra("time", notification.timestamp.toInt())
|
|
||||||
intent.putExtra("title", notification.title)
|
|
||||||
intent.putExtra("message", decodeMessage(notification))
|
|
||||||
intent.putExtra("message_bytes", decodeBytesMessage(notification))
|
|
||||||
intent.putExtra("tags", notification.tags)
|
|
||||||
intent.putExtra("tags_map", joinTagsMap(splitTags(notification.tags)))
|
|
||||||
intent.putExtra("priority", notification.priority)
|
|
||||||
intent.putExtra("muted", muted)
|
|
||||||
intent.putExtra("muted_str", muted.toString())
|
|
||||||
|
|
||||||
Log.d(TAG, "Sending message intent broadcast: ${intent.action} with extras ${intent.extras}")
|
|
||||||
ctx.sendBroadcast(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This receiver is triggered when the SEND_MESSAGE intent is received.
|
|
||||||
* See AndroidManifest.xml for details.
|
|
||||||
*/
|
|
||||||
class BroadcastReceiver : android.content.BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
Log.d(TAG, "Broadcast received: $intent")
|
|
||||||
when (intent.action) {
|
|
||||||
MESSAGE_SEND_ACTION -> send(context, intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun send(ctx: Context, intent: Intent) {
|
|
||||||
val api = ApiService(ctx)
|
|
||||||
val baseUrl = getStringExtra(intent, "base_url") ?: ctx.getString(R.string.app_base_url)
|
|
||||||
val topic = getStringExtra(intent, "topic") ?: return
|
|
||||||
val message = getStringExtra(intent, "message") ?: return
|
|
||||||
val title = getStringExtra(intent, "title") ?: ""
|
|
||||||
val tags = getStringExtra(intent,"tags") ?: ""
|
|
||||||
val priority = when (getStringExtra(intent, "priority")) {
|
|
||||||
"min", "1" -> 1
|
|
||||||
"low", "2" -> 2
|
|
||||||
"default", "3" -> 3
|
|
||||||
"high", "4" -> 4
|
|
||||||
"urgent", "max", "5" -> 5
|
|
||||||
else -> 0
|
|
||||||
}
|
|
||||||
val delay = getStringExtra(intent,"delay") ?: ""
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
Log.d(TAG, "Publishing message $intent")
|
|
||||||
api.publish(
|
|
||||||
baseUrl = baseUrl,
|
|
||||||
topic = topic,
|
|
||||||
message = message,
|
|
||||||
title = title,
|
|
||||||
priority = priority,
|
|
||||||
tags = splitTags(tags),
|
|
||||||
delay = delay
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Unable to publish message: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets an extra as a String value, even if the extra may be an int or a long.
|
|
||||||
*/
|
|
||||||
private fun getStringExtra(intent: Intent, name: String): String? {
|
|
||||||
if (intent.getStringExtra(name) != null) {
|
|
||||||
return intent.getStringExtra(name)
|
|
||||||
} else if (intent.getIntExtra(name, DOES_NOT_EXIST) != DOES_NOT_EXIST) {
|
|
||||||
return intent.getIntExtra(name, DOES_NOT_EXIST).toString()
|
|
||||||
} else if (intent.getLongExtra(name, DOES_NOT_EXIST.toLong()) != DOES_NOT_EXIST.toLong()) {
|
|
||||||
return intent.getLongExtra(name, DOES_NOT_EXIST.toLong()).toString()
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "NtfyBroadcastService"
|
|
||||||
private const val DOES_NOT_EXIST = -2586000
|
|
||||||
|
|
||||||
// These constants cannot be changed without breaking the contract; also see manifest
|
|
||||||
private const val MESSAGE_RECEIVED_ACTION = "io.heckel.ntfy.MESSAGE_RECEIVED"
|
|
||||||
private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE"
|
|
||||||
private const val USER_ACTION_ACTION = "io.heckel.ntfy.USER_ACTION"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
package com.lonecloud.sup.msg
|
|
||||||
|
|
||||||
import androidx.annotation.Keep
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
/* This annotation ensures that proguard still works in production builds,
|
|
||||||
* see https://stackoverflow.com/a/62753300/1440785 */
|
|
||||||
@Keep
|
|
||||||
data class Message(
|
|
||||||
val id: String,
|
|
||||||
val time: Long,
|
|
||||||
val event: String,
|
|
||||||
val topic: String,
|
|
||||||
val priority: Int?,
|
|
||||||
val tags: List<String>?,
|
|
||||||
val click: String?,
|
|
||||||
val icon: String?,
|
|
||||||
val actions: List<MessageAction>?,
|
|
||||||
val title: String?,
|
|
||||||
val message: String,
|
|
||||||
@SerializedName("content_type") val contentType: String?,
|
|
||||||
val encoding: String?,
|
|
||||||
val attachment: MessageAttachment?,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Keep
|
|
||||||
data class MessageAttachment(
|
|
||||||
val name: String,
|
|
||||||
val type: String?,
|
|
||||||
val size: Long?,
|
|
||||||
val expires: Long?,
|
|
||||||
val url: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Keep
|
|
||||||
data class MessageAction(
|
|
||||||
val id: String,
|
|
||||||
val action: String,
|
|
||||||
val label: String, // "view", "broadcast" or "http"
|
|
||||||
val clear: Boolean?, // clear notification after successful execution
|
|
||||||
val url: String?, // used in "view" and "http" actions
|
|
||||||
val method: String?, // used in "http" action, default is POST (!)
|
|
||||||
val headers: Map<String,String>?, // used in "http" action
|
|
||||||
val body: String?, // used in "http" action
|
|
||||||
val intent: String?, // used in "broadcast" action
|
|
||||||
val extras: Map<String,String>?, // used in "broadcast" action
|
|
||||||
)
|
|
||||||
|
|
||||||
const val MESSAGE_ENCODING_BASE64 = "base64"
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
package com.lonecloud.sup.msg
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.lonecloud.sup.db.Notification
|
|
||||||
import com.lonecloud.sup.db.Repository
|
|
||||||
import com.lonecloud.sup.db.Subscription
|
|
||||||
import com.lonecloud.sup.util.Log
|
|
||||||
import com.lonecloud.sup.up.Distributor
|
|
||||||
import com.lonecloud.sup.util.decodeBytesMessage
|
|
||||||
import com.lonecloud.sup.util.safeLet
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The notification dispatcher figures out what to do with a notification.
|
|
||||||
* It may display a notification, send out a broadcast, or forward via UnifiedPush.
|
|
||||||
*/
|
|
||||||
class NotificationDispatcher(val context: Context, val repository: Repository) {
|
|
||||||
private val notifier = NotificationService(context)
|
|
||||||
private val broadcaster = BroadcastService(context)
|
|
||||||
private val distributor = Distributor(context)
|
|
||||||
|
|
||||||
fun init() {
|
|
||||||
notifier.createNotificationChannels()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dispatch(subscription: Subscription, notification: Notification) {
|
|
||||||
Log.d(TAG, "Dispatching $notification for subscription $subscription")
|
|
||||||
|
|
||||||
val muted = getMuted(subscription)
|
|
||||||
val notify = shouldNotify(subscription, notification, muted)
|
|
||||||
val broadcast = shouldBroadcast(subscription)
|
|
||||||
val distribute = shouldDistribute(subscription)
|
|
||||||
if (notify) {
|
|
||||||
notifier.display(subscription, notification)
|
|
||||||
}
|
|
||||||
if (broadcast) {
|
|
||||||
broadcaster.sendMessage(subscription, notification, muted)
|
|
||||||
}
|
|
||||||
if (distribute) {
|
|
||||||
safeLet(subscription.upAppId, subscription.upConnectorToken) { appId, connectorToken ->
|
|
||||||
distributor.sendMessage(appId, connectorToken, decodeBytesMessage(notification))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {
|
|
||||||
if (subscription.upAppId != null) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val priority = if (notification.priority > 0) notification.priority else 3
|
|
||||||
val minPriority = if (subscription.minPriority > 0) subscription.minPriority else repository.getMinPriority()
|
|
||||||
if (priority < minPriority) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val detailsVisible = repository.detailViewSubscriptionId.get() == notification.subscriptionId
|
|
||||||
return !detailsVisible && !muted
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shouldBroadcast(subscription: Subscription): Boolean {
|
|
||||||
if (subscription.upAppId != null) { // Never broadcast for UnifiedPush subscriptions
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return repository.getBroadcastEnabled()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shouldDistribute(subscription: Subscription): Boolean {
|
|
||||||
return subscription.upAppId != null // Only distribute for UnifiedPush subscriptions
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getMuted(subscription: Subscription): Boolean {
|
|
||||||
if (repository.isGlobalMuted()) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return subscription.mutedUntil == 1L || (subscription.mutedUntil > 1L && subscription.mutedUntil > System.currentTimeMillis()/1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "NtfyNotifDispatch"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
package com.lonecloud.sup.msg
|
|
||||||
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.lonecloud.sup.db.Notification
|
|
||||||
import com.lonecloud.sup.util.joinTags
|
|
||||||
import com.lonecloud.sup.util.toPriority
|
|
||||||
import java.lang.reflect.Type
|
|
||||||
|
|
||||||
class NotificationParser {
|
|
||||||
private val gson = Gson()
|
|
||||||
|
|
||||||
fun parse(s: String, subscriptionId: Long = 0, notificationId: Int = 0): Notification? {
|
|
||||||
val notificationWithTopic = parseWithTopic(s, subscriptionId = subscriptionId, notificationId = notificationId)
|
|
||||||
return notificationWithTopic?.notification
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseWithTopic(s: String, subscriptionId: Long = 0, notificationId: Int = 0): NotificationWithTopic? {
|
|
||||||
val message = gson.fromJson(s, Message::class.java)
|
|
||||||
if (message.event != ApiService.EVENT_MESSAGE) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val notification = Notification(
|
|
||||||
id = message.id,
|
|
||||||
subscriptionId = subscriptionId,
|
|
||||||
timestamp = message.time,
|
|
||||||
title = message.title ?: "",
|
|
||||||
message = message.message,
|
|
||||||
priority = toPriority(message.priority),
|
|
||||||
tags = joinTags(message.tags),
|
|
||||||
notificationId = notificationId,
|
|
||||||
deleted = false
|
|
||||||
)
|
|
||||||
return NotificationWithTopic(message.topic, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class NotificationWithTopic(val topic: String, val notification: Notification)
|
|
||||||
}
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
package com.lonecloud.sup.msg
|
|
||||||
|
|
||||||
import android.app.*
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.media.AudioAttributes
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.media.RingtoneManager
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.lonecloud.sup.R
|
|
||||||
import com.lonecloud.sup.db.*
|
|
||||||
import com.lonecloud.sup.db.Notification
|
|
||||||
import com.lonecloud.sup.ui.Colors
|
|
||||||
|
|
||||||
import com.lonecloud.sup.ui.MainActivity
|
|
||||||
import com.lonecloud.sup.util.*
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class NotificationService(val context: Context) {
|
|
||||||
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
private val repository = Repository.getInstance(context)
|
|
||||||
private val appBaseUrl = context.getString(R.string.app_base_url)
|
|
||||||
|
|
||||||
fun display(subscription: Subscription, notification: Notification) {
|
|
||||||
displayInternal(subscription, notification, update = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun update(subscription: Subscription, notification: Notification, isNew: Boolean) {
|
|
||||||
displayInternal(subscription, notification, update = !isNew)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel(notificationId: Int) {
|
|
||||||
notificationManager.cancel(notificationId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel(subscription: Subscription, notification: Notification) {
|
|
||||||
notificationManager.cancel(notification.notificationId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createNotificationChannels() {
|
|
||||||
val groupId = DEFAULT_GROUP
|
|
||||||
val groupName = context.getString(R.string.channel_notifications_group_default_name)
|
|
||||||
maybeCreateNotificationGroup(groupId, groupName)
|
|
||||||
(PRIORITY_MIN..PRIORITY_MAX).forEach { priority ->
|
|
||||||
maybeCreateNotificationChannel(groupId, priority)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun displayInternal(subscription: Subscription, notification: Notification, update: Boolean = false) {
|
|
||||||
val title = formatTitle(appBaseUrl, subscription, notification)
|
|
||||||
val groupId = DEFAULT_GROUP
|
|
||||||
val channelId = toChannelId(groupId, notification.priority)
|
|
||||||
val insistent = notification.priority == PRIORITY_MAX &&
|
|
||||||
(repository.getInsistentMaxPriorityEnabled() || subscription.insistent == Repository.INSISTENT_MAX_PRIORITY_ENABLED)
|
|
||||||
val builder = NotificationCompat.Builder(context, channelId)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setColor(Colors.notificationIcon(context))
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
setStyleAndText(builder, notification)
|
|
||||||
setClickAction(builder, subscription)
|
|
||||||
maybeSetDeleteIntent(builder, insistent)
|
|
||||||
maybeSetSound(builder, insistent, update)
|
|
||||||
|
|
||||||
maybeCreateNotificationGroup(groupId, subscriptionGroupName(subscription))
|
|
||||||
maybeCreateNotificationChannel(groupId, notification.priority)
|
|
||||||
maybePlayInsistentSound(groupId, insistent)
|
|
||||||
|
|
||||||
notificationManager.notify(notification.notificationId, builder.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeSetDeleteIntent(builder: NotificationCompat.Builder, insistent: Boolean) {
|
|
||||||
if (!insistent) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val intent = Intent(context, DeleteBroadcastReceiver::class.java)
|
|
||||||
val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE)
|
|
||||||
builder.setDeleteIntent(pendingIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeSetSound(builder: NotificationCompat.Builder, insistent: Boolean, update: Boolean) {
|
|
||||||
val hasSound = !update && !insistent
|
|
||||||
if (hasSound) {
|
|
||||||
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
|
||||||
builder.setSound(defaultSoundUri)
|
|
||||||
} else {
|
|
||||||
builder.setSound(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setStyleAndText(builder: NotificationCompat.Builder, notification: Notification) {
|
|
||||||
val message = formatMessage(notification)
|
|
||||||
builder
|
|
||||||
.setContentText(message)
|
|
||||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setClickAction(builder: NotificationCompat.Builder, subscription: Subscription) {
|
|
||||||
val intent = Intent(context, MainActivity::class.java)
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
|
||||||
context,
|
|
||||||
kotlin.random.Random.nextInt(),
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
builder.setContentIntent(pendingIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun subscriptionGroupName(subscription: Subscription): String {
|
|
||||||
return displayName(appBaseUrl, subscription)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun displayName(appBaseUrl: String?, subscription: Subscription): String {
|
|
||||||
return subscription.displayName ?: subscriptionTopicShortUrl(subscription)
|
|
||||||
}
|
|
||||||
|
|
||||||
class DeleteBroadcastReceiver : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
Log.d(TAG, "Media player: Stopping insistent ring")
|
|
||||||
val mediaPlayer = Repository.getInstance(context).mediaPlayer
|
|
||||||
mediaPlayer.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeCreateNotificationChannel(group: String, priority: Int) {
|
|
||||||
val channelId = toChannelId(group, priority)
|
|
||||||
val pause = 300L
|
|
||||||
val channel = when (priority) {
|
|
||||||
PRIORITY_MIN -> NotificationChannel(channelId, context.getString(R.string.common_priority_min_name), NotificationManager.IMPORTANCE_MIN)
|
|
||||||
PRIORITY_LOW -> NotificationChannel(channelId, context.getString(R.string.common_priority_low_name), NotificationManager.IMPORTANCE_LOW)
|
|
||||||
PRIORITY_HIGH -> {
|
|
||||||
val channel = NotificationChannel(channelId, context.getString(R.string.common_priority_high_name), NotificationManager.IMPORTANCE_HIGH)
|
|
||||||
channel.enableVibration(true)
|
|
||||||
channel.vibrationPattern = longArrayOf(
|
|
||||||
pause, 100, pause, 100, pause, 100,
|
|
||||||
pause, 2000
|
|
||||||
)
|
|
||||||
channel
|
|
||||||
}
|
|
||||||
PRIORITY_MAX -> {
|
|
||||||
val channel = NotificationChannel(channelId, context.getString(R.string.common_priority_max_name), NotificationManager.IMPORTANCE_HIGH)
|
|
||||||
channel.enableLights(true)
|
|
||||||
channel.enableVibration(true)
|
|
||||||
channel.setBypassDnd(true)
|
|
||||||
channel.vibrationPattern = longArrayOf(
|
|
||||||
pause, 100, pause, 100, pause, 100,
|
|
||||||
pause, 2000,
|
|
||||||
pause, 100, pause, 100, pause, 100,
|
|
||||||
pause, 2000,
|
|
||||||
pause, 100, pause, 100, pause, 100,
|
|
||||||
pause, 2000
|
|
||||||
)
|
|
||||||
channel
|
|
||||||
}
|
|
||||||
else -> NotificationChannel(channelId, context.getString(R.string.common_priority_default_name), NotificationManager.IMPORTANCE_DEFAULT)
|
|
||||||
}
|
|
||||||
channel.group = group
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeDeleteNotificationChannel(group: String, priority: Int) {
|
|
||||||
notificationManager.deleteNotificationChannel(toChannelId(group, priority))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeCreateNotificationGroup(id: String, name: String) {
|
|
||||||
notificationManager.createNotificationChannelGroup(NotificationChannelGroup(id, name))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeDeleteNotificationGroup(id: String) {
|
|
||||||
notificationManager.deleteNotificationChannelGroup(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toChannelId(groupId: String, priority: Int): String {
|
|
||||||
return when (priority) {
|
|
||||||
PRIORITY_MIN -> groupId + GROUP_SUFFIX_PRIORITY_MIN
|
|
||||||
PRIORITY_LOW -> groupId + GROUP_SUFFIX_PRIORITY_LOW
|
|
||||||
PRIORITY_HIGH -> groupId + GROUP_SUFFIX_PRIORITY_HIGH
|
|
||||||
PRIORITY_MAX -> groupId + GROUP_SUFFIX_PRIORITY_MAX
|
|
||||||
else -> groupId + GROUP_SUFFIX_PRIORITY_DEFAULT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybePlayInsistentSound(groupId: String, insistent: Boolean) {
|
|
||||||
if (!insistent) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val mediaPlayer = repository.mediaPlayer
|
|
||||||
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
||||||
if (audioManager.getStreamVolume(AudioManager.STREAM_ALARM) != 0) {
|
|
||||||
Log.d(TAG, "Media player: Playing insistent alarm on alarm channel")
|
|
||||||
mediaPlayer.reset()
|
|
||||||
mediaPlayer.setDataSource(context, getInsistentSound(groupId))
|
|
||||||
mediaPlayer.setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).build())
|
|
||||||
mediaPlayer.isLooping = true
|
|
||||||
mediaPlayer.prepare()
|
|
||||||
mediaPlayer.start()
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Media player: Alarm volume is 0; not playing insistent alarm")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Media player: Failed to play insistent alarm", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getInsistentSound(groupId: String): Uri {
|
|
||||||
val channelId = toChannelId(groupId, PRIORITY_MAX)
|
|
||||||
val channel = notificationManager.getNotificationChannel(channelId)
|
|
||||||
return channel.sound
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "NtfyNotifService"
|
|
||||||
private const val DEFAULT_GROUP = "ntfy"
|
|
||||||
private const val SUBSCRIPTION_GROUP_PREFIX = "ntfy-subscription-"
|
|
||||||
private const val GROUP_SUFFIX_PRIORITY_MIN = "-min"
|
|
||||||
private const val GROUP_SUFFIX_PRIORITY_LOW = "-low"
|
|
||||||
private const val GROUP_SUFFIX_PRIORITY_DEFAULT = ""
|
|
||||||
private const val GROUP_SUFFIX_PRIORITY_HIGH = "-high"
|
|
||||||
private const val GROUP_SUFFIX_PRIORITY_MAX = "-max"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
package com.lonecloud.sup.service
|
|
||||||
|
|
||||||
class NotAuthorizedException(message: String, val user: Any? = null) : Exception(message)
|
|
||||||
|
|
||||||
fun Throwable.hasCause(causeClass: Class<out Throwable>): Boolean {
|
|
||||||
var current: Throwable? = this
|
|
||||||
while (current != null) {
|
|
||||||
if (causeClass.isInstance(current)) return true
|
|
||||||
current = current.cause
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
package com.lonecloud.sup.service
|
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.lonecloud.sup.R
|
|
||||||
import com.lonecloud.sup.app.Application
|
|
||||||
import com.lonecloud.sup.db.Notification
|
|
||||||
import com.lonecloud.sup.msg.NotificationDispatcher
|
|
||||||
import com.lonecloud.sup.util.Log
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service that listens for Signal notifications and processes them.
|
|
||||||
* This replaces ntfy's SubscriberService which polls HTTP/WebSocket.
|
|
||||||
* We get notifications pushed via Signal instead.
|
|
||||||
*/
|
|
||||||
class SignalListenerService : Service() {
|
|
||||||
private val repository by lazy { (application as Application).repository }
|
|
||||||
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
Log.d(TAG, "Service created")
|
|
||||||
createForegroundNotification()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
Log.d(TAG, "Service started")
|
|
||||||
|
|
||||||
intent?.getStringExtra(EXTRA_NOTIFICATION_DATA)?.let { data ->
|
|
||||||
scope.launch {
|
|
||||||
processNotification(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
|
|
||||||
private fun createForegroundNotification() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
NOTIFICATION_CHANNEL_ID,
|
|
||||||
getString(R.string.channel_subscriber_service_name),
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
)
|
|
||||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
|
||||||
.setContentTitle(getString(R.string.app_name))
|
|
||||||
.setContentText("Listening for notifications via Signal")
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun processNotification(data: String) {
|
|
||||||
try {
|
|
||||||
Log.d(TAG, "Processing notification: $data")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error processing notification: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "NtfySignalListener"
|
|
||||||
private const val NOTIFICATION_CHANNEL_ID = "ntfy-signal"
|
|
||||||
private const val NOTIFICATION_ID = 1
|
|
||||||
const val EXTRA_NOTIFICATION_DATA = "notification_data"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,466 +0,0 @@
|
||||||
package com.lonecloud.sup.ui
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.app.Dialog
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.view.inputmethod.InputMethodManager
|
|
||||||
import android.widget.*
|
|
||||||
import androidx.fragment.app.DialogFragment
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import com.google.android.material.appbar.MaterialToolbar
|
|
||||||
import com.google.android.material.textfield.TextInputEditText
|
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
|
||||||
import com.lonecloud.sup.BuildConfig
|
|
||||||
import com.lonecloud.sup.R
|
|
||||||
import com.lonecloud.sup.db.Repository
|
|
||||||
import com.lonecloud.sup.msg.ApiService
|
|
||||||
import com.lonecloud.sup.util.CertUtil
|
|
||||||
import com.lonecloud.sup.util.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.core.view.isGone
|
|
||||||
import java.security.cert.CertificateException
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.net.ssl.SSLHandshakeException
|
|
||||||
import javax.net.ssl.SSLPeerUnverifiedException
|
|
||||||
|
|
||||||
class AddFragment : DialogFragment() {
|
|
||||||
private lateinit var repository: Repository
|
|
||||||
private lateinit var api: ApiService
|
|
||||||
private lateinit var subscribeListener: SubscribeListener
|
|
||||||
private lateinit var appBaseUrl: String
|
|
||||||
private var defaultBaseUrl: String? = null
|
|
||||||
|
|
||||||
private lateinit var toolbar: MaterialToolbar
|
|
||||||
private lateinit var actionMenuItem: MenuItem
|
|
||||||
private lateinit var subscribeView: View
|
|
||||||
private lateinit var loginView: View
|
|
||||||
|
|
||||||
// Subscribe page
|
|
||||||
private lateinit var subscribeTopicText: TextInputEditText
|
|
||||||
private lateinit var subscribeBaseUrlLayout: TextInputLayout
|
|
||||||
private lateinit var subscribeBaseUrlText: AutoCompleteTextView
|
|
||||||
private lateinit var subscribeUseAnotherServerCheckbox: CheckBox
|
|
||||||
private lateinit var subscribeUseAnotherServerDescription: TextView
|
|
||||||
private lateinit var subscribeInstantDeliveryBox: View
|
|
||||||
private lateinit var subscribeInstantDeliveryCheckbox: CheckBox
|
|
||||||
private lateinit var subscribeInstantDeliveryDescription: View
|
|
||||||
private lateinit var subscribeForegroundDescription: TextView
|
|
||||||
private lateinit var subscribeProgress: ProgressBar
|
|
||||||
private lateinit var subscribeErrorText: TextView
|
|
||||||
private lateinit var subscribeErrorTextImage: View
|
|
||||||
|
|
||||||
// Login page
|
|
||||||
private lateinit var loginUsernameText: TextInputEditText
|
|
||||||
private lateinit var loginPasswordText: TextInputEditText
|
|
||||||
private lateinit var loginProgress: ProgressBar
|
|
||||||
private lateinit var loginErrorText: TextView
|
|
||||||
private lateinit var loginErrorTextImage: View
|
|
||||||
|
|
||||||
interface SubscribeListener {
|
|
||||||
fun onSubscribe(topic: String, baseUrl: String, instant: Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAttach(context: Context) {
|
|
||||||
super.onAttach(context)
|
|
||||||
subscribeListener = activity as SubscribeListener
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
|
||||||
if (activity == null) {
|
|
||||||
throw IllegalStateException("Activity cannot be null")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dependencies (Fragments need a default constructor)
|
|
||||||
repository = Repository.getInstance(requireActivity())
|
|
||||||
api = ApiService(requireContext())
|
|
||||||
appBaseUrl = getString(R.string.app_base_url)
|
|
||||||
defaultBaseUrl = repository.getDefaultBaseUrl()
|
|
||||||
|
|
||||||
// Build root view
|
|
||||||
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_add_dialog, null)
|
|
||||||
|
|
||||||
// Setup toolbar
|
|
||||||
toolbar = view.findViewById(R.id.add_dialog_toolbar)
|
|
||||||
toolbar.setNavigationOnClickListener {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
toolbar.setOnMenuItemClickListener { menuItem ->
|
|
||||||
if (menuItem.itemId == R.id.add_dialog_action_button) {
|
|
||||||
onActionButtonClick()
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
actionMenuItem = toolbar.menu.findItem(R.id.add_dialog_action_button)
|
|
||||||
|
|
||||||
// Main "pages"
|
|
||||||
subscribeView = view.findViewById(R.id.add_dialog_subscribe_view)
|
|
||||||
subscribeView.visibility = View.VISIBLE
|
|
||||||
loginView = view.findViewById(R.id.add_dialog_login_view)
|
|
||||||
loginView.visibility = View.GONE
|
|
||||||
|
|
||||||
// Fields for "subscribe page"
|
|
||||||
subscribeTopicText = view.findViewById(R.id.add_dialog_subscribe_topic_text)
|
|
||||||
subscribeBaseUrlLayout = view.findViewById(R.id.add_dialog_subscribe_base_url_layout)
|
|
||||||
subscribeBaseUrlLayout.background = view.background
|
|
||||||
subscribeBaseUrlLayout.makeEndIconSmaller(resources) // Hack!
|
|
||||||
subscribeBaseUrlText = view.findViewById(R.id.add_dialog_subscribe_base_url_text)
|
|
||||||
subscribeBaseUrlText.background = view.background
|
|
||||||
subscribeBaseUrlText.hint = defaultBaseUrl ?: appBaseUrl
|
|
||||||
subscribeInstantDeliveryBox = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_box)
|
|
||||||
subscribeInstantDeliveryCheckbox = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_checkbox)
|
|
||||||
subscribeInstantDeliveryDescription = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_description)
|
|
||||||
subscribeUseAnotherServerCheckbox = view.findViewById(R.id.add_dialog_subscribe_use_another_server_checkbox)
|
|
||||||
subscribeUseAnotherServerDescription = view.findViewById(R.id.add_dialog_subscribe_use_another_server_description)
|
|
||||||
subscribeForegroundDescription = view.findViewById(R.id.add_dialog_subscribe_foreground_description)
|
|
||||||
subscribeProgress = view.findViewById(R.id.add_dialog_subscribe_progress)
|
|
||||||
subscribeErrorText = view.findViewById(R.id.add_dialog_subscribe_error_text)
|
|
||||||
subscribeErrorText.visibility = View.GONE
|
|
||||||
subscribeErrorTextImage = view.findViewById(R.id.add_dialog_subscribe_error_text_image)
|
|
||||||
subscribeErrorTextImage.visibility = View.GONE
|
|
||||||
|
|
||||||
// Fields for "login page"
|
|
||||||
loginUsernameText = view.findViewById(R.id.add_dialog_login_username)
|
|
||||||
loginPasswordText = view.findViewById(R.id.add_dialog_login_password)
|
|
||||||
loginProgress = view.findViewById(R.id.add_dialog_login_progress)
|
|
||||||
loginErrorText = view.findViewById(R.id.add_dialog_login_error_text)
|
|
||||||
loginErrorTextImage = view.findViewById(R.id.add_dialog_login_error_text_image)
|
|
||||||
|
|
||||||
// Set foreground description text
|
|
||||||
subscribeForegroundDescription.text = getString(R.string.add_dialog_foreground_description, shortUrl(appBaseUrl))
|
|
||||||
|
|
||||||
// Hide instant delivery UI (no Firebase)
|
|
||||||
subscribeInstantDeliveryBox.visibility = View.GONE
|
|
||||||
|
|
||||||
// Add baseUrl auto-complete behavior
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
val baseUrlsRaw = repository.getSubscriptions()
|
|
||||||
.groupBy { it.baseUrl }
|
|
||||||
.map { it.key }
|
|
||||||
.filterNot { it == appBaseUrl }
|
|
||||||
val baseUrls = if (defaultBaseUrl != null) {
|
|
||||||
(baseUrlsRaw.filterNot { it == defaultBaseUrl } + appBaseUrl).sorted()
|
|
||||||
} else {
|
|
||||||
baseUrlsRaw.sorted()
|
|
||||||
}
|
|
||||||
val activity = activity ?: return@launch // We may have pressed "Cancel"
|
|
||||||
activity.runOnUiThread {
|
|
||||||
initBaseUrlDropdown(baseUrls, subscribeBaseUrlText, subscribeBaseUrlLayout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe view validation
|
|
||||||
val subscribeTextWatcher = AfterChangedTextWatcher {
|
|
||||||
validateInputSubscribeView()
|
|
||||||
}
|
|
||||||
subscribeTopicText.addTextChangedListener(subscribeTextWatcher)
|
|
||||||
subscribeBaseUrlText.addTextChangedListener(subscribeTextWatcher)
|
|
||||||
subscribeInstantDeliveryCheckbox.setOnCheckedChangeListener { _, _ ->
|
|
||||||
validateInputSubscribeView()
|
|
||||||
}
|
|
||||||
subscribeUseAnotherServerCheckbox.setOnCheckedChangeListener { _, _ ->
|
|
||||||
validateInputSubscribeView()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Username/password validation on type
|
|
||||||
val loginTextWatcher = AfterChangedTextWatcher {
|
|
||||||
validateInputLoginView()
|
|
||||||
}
|
|
||||||
loginUsernameText.addTextChangedListener(loginTextWatcher)
|
|
||||||
loginPasswordText.addTextChangedListener(loginTextWatcher)
|
|
||||||
|
|
||||||
// Build dialog
|
|
||||||
val dialog = Dialog(requireContext(), R.style.Theme_App_FullScreenDialog)
|
|
||||||
dialog.setContentView(view)
|
|
||||||
|
|
||||||
// Initial validation
|
|
||||||
validateInputSubscribeView()
|
|
||||||
|
|
||||||
return dialog
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
dialog?.window?.apply {
|
|
||||||
setLayout(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
// Show keyboard after the dialog is fully visible
|
|
||||||
subscribeTopicText.postDelayed({
|
|
||||||
subscribeTopicText.requestFocus()
|
|
||||||
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
|
||||||
imm?.showSoftInput(subscribeTopicText, InputMethodManager.SHOW_IMPLICIT)
|
|
||||||
}, 200)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onActionButtonClick() {
|
|
||||||
val topic = subscribeTopicText.text.toString()
|
|
||||||
val baseUrl = getBaseUrl()
|
|
||||||
if (subscribeView.isVisible) {
|
|
||||||
checkReadAndMaybeShowLogin(baseUrl, topic)
|
|
||||||
} else if (loginView.isVisible) {
|
|
||||||
loginAndMaybeDismiss(baseUrl, topic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkReadAndMaybeShowLogin(baseUrl: String, topic: String) {
|
|
||||||
subscribeProgress.visibility = View.VISIBLE
|
|
||||||
subscribeErrorText.visibility = View.GONE
|
|
||||||
subscribeErrorTextImage.visibility = View.GONE
|
|
||||||
enableSubscribeView(false)
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val authorized = api.checkAuth(baseUrl, topic)
|
|
||||||
if (authorized) {
|
|
||||||
Log.d(TAG, "Access granted to topic ${topicUrl(baseUrl, topic)}")
|
|
||||||
dismissDialog()
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog")
|
|
||||||
val activity = activity ?: return@launch // We may have pressed "Cancel"
|
|
||||||
activity.runOnUiThread {
|
|
||||||
showLoginView(activity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Connection to topic failed: ${e.message}", e)
|
|
||||||
|
|
||||||
// If this is an SSL certificate error, show the trust cert dialog
|
|
||||||
// Never show the dialog for the app base URL
|
|
||||||
if (isSSLException(e) && baseUrl != appBaseUrl) {
|
|
||||||
Log.d(TAG, "SSL certificate error detected, attempting to fetch certificate for user review")
|
|
||||||
handleSSLException(baseUrl)
|
|
||||||
} else {
|
|
||||||
showErrorAndReenableSubscribeView(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isSSLException(e: Exception): Boolean {
|
|
||||||
var cause: Throwable? = e
|
|
||||||
while (cause != null) {
|
|
||||||
if (cause is SSLHandshakeException || cause is SSLPeerUnverifiedException || cause is CertificateException) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
cause = cause.cause
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleSSLException(baseUrl: String) {
|
|
||||||
// Try to fetch the server's certificate
|
|
||||||
val activity = activity ?: return
|
|
||||||
val certUtil = CertUtil.getInstance(requireContext())
|
|
||||||
val certificate = certUtil.fetchServerCertificate(baseUrl)
|
|
||||||
activity.runOnUiThread {
|
|
||||||
if (certificate != null) {
|
|
||||||
showCertificateTrustDialog(baseUrl, certificate)
|
|
||||||
} else {
|
|
||||||
// Could not fetch certificate, show generic SSL error
|
|
||||||
showErrorAndReenableSubscribeView(getString(R.string.add_dialog_error_ssl_untrusted))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showCertificateTrustDialog(baseUrl: String, certificate: X509Certificate) {
|
|
||||||
subscribeProgress.visibility = View.GONE
|
|
||||||
enableSubscribeView(true)
|
|
||||||
showErrorAndReenableSubscribeView(getString(R.string.add_dialog_error_ssl_untrusted))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showErrorAndReenableSubscribeView(message: String?) {
|
|
||||||
val activity = activity ?: return // We may have pressed "Cancel"
|
|
||||||
activity.runOnUiThread {
|
|
||||||
subscribeProgress.visibility = View.GONE
|
|
||||||
subscribeErrorText.visibility = View.VISIBLE
|
|
||||||
subscribeErrorText.text = message
|
|
||||||
subscribeErrorTextImage.visibility = View.VISIBLE
|
|
||||||
enableSubscribeView(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loginAndMaybeDismiss(baseUrl: String, topic: String) {
|
|
||||||
loginProgress.visibility = View.VISIBLE
|
|
||||||
loginErrorText.visibility = View.GONE
|
|
||||||
loginErrorTextImage.visibility = View.GONE
|
|
||||||
enableLoginView(false)
|
|
||||||
val username = loginUsernameText.text.toString()
|
|
||||||
val password = loginPasswordText.text.toString()
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
Log.d(TAG, "Checking read access for user $username to topic ${topicUrl(baseUrl, topic)}")
|
|
||||||
try {
|
|
||||||
val authorized = api.checkAuth(baseUrl, topic)
|
|
||||||
if (authorized) {
|
|
||||||
Log.d(TAG, "Access granted for user $username to topic ${topicUrl(baseUrl, topic)}")
|
|
||||||
dismissDialog()
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "Access not allowed for user $username to topic ${topicUrl(baseUrl, topic)}")
|
|
||||||
showErrorAndReenableLoginView(getString(R.string.add_dialog_login_error_not_authorized, username))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Connection to topic failed during login: ${e.message}", e)
|
|
||||||
showErrorAndReenableLoginView(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showErrorAndReenableLoginView(message: String?) {
|
|
||||||
val activity = activity ?: return // We may have pressed "Cancel"
|
|
||||||
activity.runOnUiThread {
|
|
||||||
loginProgress.visibility = View.GONE
|
|
||||||
loginErrorText.visibility = View.VISIBLE
|
|
||||||
loginErrorText.text = message
|
|
||||||
loginErrorTextImage.visibility = View.VISIBLE
|
|
||||||
enableLoginView(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun validateInputSubscribeView() {
|
|
||||||
if (!this::actionMenuItem.isInitialized) return // As per crash seen in Google Play
|
|
||||||
|
|
||||||
// Show/hide server selection UI
|
|
||||||
if (subscribeUseAnotherServerCheckbox.isChecked) {
|
|
||||||
subscribeUseAnotherServerDescription.visibility = View.VISIBLE
|
|
||||||
subscribeBaseUrlLayout.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
subscribeUseAnotherServerDescription.visibility = View.GONE
|
|
||||||
subscribeBaseUrlLayout.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable/disable "Subscribe" button
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
val baseUrl = getBaseUrl()
|
|
||||||
val topic = subscribeTopicText.text.toString()
|
|
||||||
val subscription = repository.getSubscription(baseUrl, topic)
|
|
||||||
|
|
||||||
activity?.let {
|
|
||||||
it.runOnUiThread {
|
|
||||||
if (subscription != null || DISALLOWED_TOPICS.contains(topic)) {
|
|
||||||
actionMenuItem.isEnabled = false
|
|
||||||
} else if (subscribeUseAnotherServerCheckbox.isChecked) {
|
|
||||||
actionMenuItem.isEnabled = validTopic(topic) && validUrl(baseUrl)
|
|
||||||
} else {
|
|
||||||
actionMenuItem.isEnabled = validTopic(topic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun validateInputLoginView() {
|
|
||||||
if (!this::actionMenuItem.isInitialized || !this::loginUsernameText.isInitialized || !this::loginPasswordText.isInitialized) {
|
|
||||||
return // As per crash seen in Google Play
|
|
||||||
}
|
|
||||||
if (loginUsernameText.isGone) {
|
|
||||||
actionMenuItem.isEnabled = true
|
|
||||||
} else {
|
|
||||||
actionMenuItem.isEnabled = (loginUsernameText.text?.isNotEmpty() ?: false)
|
|
||||||
&& (loginPasswordText.text?.isNotEmpty() ?: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dismissDialog() {
|
|
||||||
Log.d(TAG, "Closing dialog and calling onSubscribe handler")
|
|
||||||
val activity = activity?: return // We may have pressed "Cancel"
|
|
||||||
activity.runOnUiThread {
|
|
||||||
val baseUrl = getBaseUrl()
|
|
||||||
val topic = subscribeTopicText.text.toString()
|
|
||||||
val instant = true // Always use foreground service (no Firebase)
|
|
||||||
subscribeListener.onSubscribe(topic, baseUrl, instant)
|
|
||||||
dialog?.dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getBaseUrl(): String {
|
|
||||||
return if (subscribeUseAnotherServerCheckbox.isChecked) {
|
|
||||||
subscribeBaseUrlText.text.toString()
|
|
||||||
} else {
|
|
||||||
return defaultBaseUrl ?: appBaseUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showSubscribeView() {
|
|
||||||
resetSubscribeView()
|
|
||||||
toolbar.setTitle(R.string.add_dialog_title)
|
|
||||||
actionMenuItem.setTitle(R.string.add_dialog_button_subscribe)
|
|
||||||
toolbar.setNavigationOnClickListener {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
loginView.visibility = View.GONE
|
|
||||||
subscribeView.visibility = View.VISIBLE
|
|
||||||
if (subscribeTopicText.requestFocus()) {
|
|
||||||
val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
|
||||||
imm?.showSoftInput(subscribeTopicText, InputMethodManager.SHOW_IMPLICIT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showLoginView(activity: Activity) {
|
|
||||||
resetLoginView()
|
|
||||||
loginProgress.visibility = View.INVISIBLE
|
|
||||||
toolbar.setTitle(R.string.add_dialog_login_title)
|
|
||||||
actionMenuItem.setTitle(R.string.add_dialog_button_login)
|
|
||||||
toolbar.setNavigationOnClickListener {
|
|
||||||
showSubscribeView()
|
|
||||||
}
|
|
||||||
subscribeView.visibility = View.GONE
|
|
||||||
loginView.visibility = View.VISIBLE
|
|
||||||
if (loginUsernameText.requestFocus()) {
|
|
||||||
val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
|
||||||
imm?.showSoftInput(loginUsernameText, InputMethodManager.SHOW_IMPLICIT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun enableSubscribeView(enable: Boolean) {
|
|
||||||
subscribeTopicText.isEnabled = enable
|
|
||||||
subscribeBaseUrlText.isEnabled = enable
|
|
||||||
subscribeInstantDeliveryCheckbox.isEnabled = enable
|
|
||||||
subscribeUseAnotherServerCheckbox.isEnabled = enable
|
|
||||||
actionMenuItem.isEnabled = enable
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resetSubscribeView() {
|
|
||||||
subscribeProgress.visibility = View.GONE
|
|
||||||
subscribeErrorText.visibility = View.GONE
|
|
||||||
subscribeErrorTextImage.visibility = View.GONE
|
|
||||||
enableSubscribeView(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun enableLoginView(enable: Boolean) {
|
|
||||||
loginUsernameText.isEnabled = enable
|
|
||||||
loginPasswordText.isEnabled = enable
|
|
||||||
actionMenuItem.isEnabled = enable
|
|
||||||
if (enable && loginUsernameText.requestFocus()) {
|
|
||||||
val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
|
||||||
imm?.showSoftInput(loginUsernameText, InputMethodManager.SHOW_IMPLICIT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resetLoginView() {
|
|
||||||
loginProgress.visibility = View.GONE
|
|
||||||
loginErrorText.visibility = View.GONE
|
|
||||||
loginErrorTextImage.visibility = View.GONE
|
|
||||||
loginUsernameText.visibility = View.VISIBLE
|
|
||||||
loginUsernameText.text?.clear()
|
|
||||||
loginPasswordText.visibility = View.VISIBLE
|
|
||||||
loginPasswordText.text?.clear()
|
|
||||||
enableLoginView(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val TAG = "NtfyAddFragment"
|
|
||||||
private val DISALLOWED_TOPICS = listOf("docs", "static", "file") // If updated, also update in server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
package com.lonecloud.sup.ui
|
|
||||||
|
|
||||||
import android.text.Editable
|
|
||||||
import android.text.TextWatcher
|
|
||||||
import android.widget.ArrayAdapter
|
|
||||||
import android.widget.AutoCompleteTextView
|
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
|
||||||
import com.lonecloud.sup.R
|
|
||||||
|
|
||||||
fun initBaseUrlDropdown(baseUrls: List<String>, textView: AutoCompleteTextView, layout: TextInputLayout) {
|
|
||||||
// Base URL dropdown behavior; Oh my, why is this so complicated?!
|
|
||||||
val context = layout.context
|
|
||||||
val toggleEndIcon = {
|
|
||||||
if (textView.text.isNotEmpty()) {
|
|
||||||
layout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp)
|
|
||||||
layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_clear)
|
|
||||||
} else if (baseUrls.isEmpty()) {
|
|
||||||
layout.setEndIconDrawable(0)
|
|
||||||
layout.endIconContentDescription = ""
|
|
||||||
} else {
|
|
||||||
layout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
|
|
||||||
layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_choose)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
layout.setEndIconOnClickListener {
|
|
||||||
if (textView.text.isNotEmpty()) {
|
|
||||||
textView.text.clear()
|
|
||||||
if (baseUrls.isEmpty()) {
|
|
||||||
layout.setEndIconDrawable(0)
|
|
||||||
layout.endIconContentDescription = ""
|
|
||||||
} else {
|
|
||||||
layout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
|
|
||||||
layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_choose)
|
|
||||||
}
|
|
||||||
} else if (textView.text.isEmpty() && baseUrls.isNotEmpty()) {
|
|
||||||
layout.setEndIconDrawable(R.drawable.ic_drop_up_gray_24dp)
|
|
||||||
layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_choose)
|
|
||||||
textView.showDropDown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
textView.setOnDismissListener { toggleEndIcon() }
|
|
||||||
textView.addTextChangedListener(object : TextWatcher {
|
|
||||||
override fun afterTextChanged(s: Editable?) {
|
|
||||||
toggleEndIcon()
|
|
||||||
}
|
|
||||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
|
||||||
// Nothing
|
|
||||||
}
|
|
||||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
|
||||||
// Nothing
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
val adapter = ArrayAdapter(textView.context, R.layout.fragment_add_dialog_dropdown_item, baseUrls)
|
|
||||||
textView.threshold = 1
|
|
||||||
textView.setAdapter(adapter)
|
|
||||||
if (baseUrls.count() == 1) {
|
|
||||||
layout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp)
|
|
||||||
layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_clear)
|
|
||||||
textView.setText(baseUrls.first())
|
|
||||||
} else if (baseUrls.count() > 1) {
|
|
||||||
layout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
|
|
||||||
layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_choose)
|
|
||||||
} else {
|
|
||||||
layout.setEndIconDrawable(0)
|
|
||||||
layout.endIconContentDescription = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
package com.lonecloud.sup.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Color
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.google.android.material.color.MaterialColors
|
|
||||||
import com.lonecloud.sup.R
|
|
||||||
import com.lonecloud.sup.util.isDarkThemeOn
|
|
||||||
|
|
||||||
class Colors {
|
|
||||||
companion object {
|
|
||||||
fun primary(context: Context): Int {
|
|
||||||
return MaterialColors.getColor(context, android.R.attr.colorPrimary, Color.GREEN)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onPrimary(context: Context): Int {
|
|
||||||
return MaterialColors.getColor(context, com.google.android.material.R.attr.colorOnPrimary, Color.GREEN)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun notificationIcon(context: Context): Int {
|
|
||||||
return MaterialColors.getColor(context, android.R.attr.colorPrimary, Color.GREEN)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun linkColor(context: Context): Int {
|
|
||||||
return MaterialColors.getColor(context, android.R.attr.colorPrimary, Color.GREEN)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun itemSelectedBackground(context: Context): Int {
|
|
||||||
return ContextCompat.getColor(context, R.color.md_theme_surfaceContainerHigh)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cardBackgroundColor(context: Context): Int {
|
|
||||||
return if (isDarkThemeOn(context)) {
|
|
||||||
MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurfaceContainer, Color.GRAY)
|
|
||||||
} else {
|
|
||||||
MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.WHITE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cardSelectedBackgroundColor(context: Context): Int {
|
|
||||||
return if (isDarkThemeOn(context)) {
|
|
||||||
MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurfaceContainerHigh, Color.GRAY)
|
|
||||||
} else {
|
|
||||||
MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurfaceContainerHighest, Color.GRAY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun statusBarNormal(context: Context, dynamicColors: Boolean, darkMode: Boolean): Int {
|
|
||||||
val default = context.resources.getColor(R.color.action_bar, null)
|
|
||||||
return if (dynamicColors) {
|
|
||||||
// Use colorSurface for both light and dark mode when dynamic colors are enabled
|
|
||||||
MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, default)
|
|
||||||
} else {
|
|
||||||
default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shouldUseLightStatusBar(dynamicColors: Boolean, darkMode: Boolean): Boolean {
|
|
||||||
// Use light status bar (dark icons) when dynamic colors are enabled in light mode
|
|
||||||
return dynamicColors && !darkMode
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toolbarTextColor(context: Context, dynamicColors: Boolean, darkMode: Boolean): Int {
|
|
||||||
return if (dynamicColors) {
|
|
||||||
// Use colorOnSurface (dark on light, light on dark) when dynamic colors are enabled
|
|
||||||
MaterialColors.getColor(context, com.google.android.material.R.attr.colorOnSurface, Color.BLACK)
|
|
||||||
} else {
|
|
||||||
if (darkMode) {
|
|
||||||
// In dark mode, toolbar is gray (surfaceContainer), so use light text
|
|
||||||
MaterialColors.getColor(context, com.google.android.material.R.attr.colorOnSurface, Color.WHITE)
|
|
||||||
} else {
|
|
||||||
// In light mode, toolbar is teal (primary), so use white text
|
|
||||||
MaterialColors.getColor(context, com.google.android.material.R.attr.colorOnPrimary, Color.WHITE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dangerText(context: Context): Int {
|
|
||||||
return ContextCompat.getColor(context, android.R.color.holo_red_dark)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun swipeToRefreshColor(context: Context): Int {
|
|
||||||
return MaterialColors.getColor(context, android.R.attr.colorPrimary, Color.GREEN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
package com.lonecloud.sup.ui
|
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.lonecloud.sup.db.Notification
|
|
||||||
import com.lonecloud.sup.db.Repository
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class DetailViewModel(private val repository: Repository) : ViewModel() {
|
|
||||||
fun list(subscriptionId: Long): LiveData<List<Notification>> {
|
|
||||||
return repository.getNotificationsLiveData(subscriptionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun markAsDeleted(notificationId: String) = viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
repository.markAsDeleted(notificationId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DetailViewModelFactory(private val repository: Repository) : ViewModelProvider.Factory {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
|
||||||
with(modelClass){
|
|
||||||
when {
|
|
||||||
isAssignableFrom(DetailViewModel::class.java) -> DetailViewModel(repository) as T
|
|
||||||
else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,652 +0,0 @@
|
||||||
package com.lonecloud.sup.ui
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.animation.Animator
|
|
||||||
import android.animation.AnimatorListenerAdapter
|
|
||||||
import android.app.AlarmManager
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
|
|
||||||
import android.text.method.LinkMovementMethod
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.Button
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.appcompat.view.ActionMode
|
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.content.res.ResourcesCompat
|
|
||||||
import androidx.core.text.HtmlCompat
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.google.android.material.appbar.AppBarLayout
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
|
||||||
import com.lonecloud.sup.BuildConfig
|
|
||||||
import com.lonecloud.sup.R
|
|
||||||
import com.lonecloud.sup.app.Application
|
|
||||||
import com.lonecloud.sup.db.Repository
|
|
||||||
import com.lonecloud.sup.db.Subscription
|
|
||||||
import com.lonecloud.sup.msg.ApiService
|
|
||||||
import com.lonecloud.sup.msg.NotificationDispatcher
|
|
||||||
import com.lonecloud.sup.util.Log
|
|
||||||
import com.lonecloud.sup.util.dangerButton
|
|
||||||
import com.lonecloud.sup.util.displayName
|
|
||||||
import com.lonecloud.sup.util.formatDateShort
|
|
||||||
import com.lonecloud.sup.util.isDarkThemeOn
|
|
||||||
import com.lonecloud.sup.util.isIgnoringBatteryOptimizations
|
|
||||||
import com.lonecloud.sup.util.maybeSplitTopicUrl
|
|
||||||
import com.lonecloud.sup.util.randomSubscriptionId
|
|
||||||
import com.lonecloud.sup.util.shortUrl
|
|
||||||
import com.lonecloud.sup.util.topicShortUrl
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.random.Random
|
|
||||||
import androidx.core.view.size
|
|
||||||
import androidx.core.view.get
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener {
|
|
||||||
private val viewModel by viewModels<SubscriptionsViewModel> {
|
|
||||||
SubscriptionsViewModelFactory((application as Application).repository)
|
|
||||||
}
|
|
||||||
private val repository by lazy { (application as Application).repository }
|
|
||||||
private val api by lazy { ApiService(this) }
|
|
||||||
|
|
||||||
// UI elements
|
|
||||||
private lateinit var menu: Menu
|
|
||||||
private lateinit var mainList: RecyclerView
|
|
||||||
private lateinit var adapter: MainAdapter
|
|
||||||
private lateinit var fab: FloatingActionButton
|
|
||||||
|
|
||||||
// Other stuff
|
|
||||||
private var dispatcher: NotificationDispatcher? = null // Context-dependent
|
|
||||||
private var appBaseUrl: String? = null // Context-dependent
|
|
||||||
|
|
||||||
// Action mode stuff
|
|
||||||
private var actionMode: ActionMode? = null
|
|
||||||
private val actionModeCallback = object : ActionMode.Callback {
|
|
||||||
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
|
||||||
actionMode = mode
|
|
||||||
if (mode != null) {
|
|
||||||
mode.menuInflater.inflate(R.menu.menu_main_action_mode, menu)
|
|
||||||
mode.title = "1" // One item selected
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
|
|
||||||
|
|
||||||
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem): Boolean {
|
|
||||||
return when (item.itemId) {
|
|
||||||
R.id.main_action_mode_delete -> {
|
|
||||||
onMultiDeleteClick()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyActionMode(mode: ActionMode?) {
|
|
||||||
endActionModeAndRedraw()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_main)
|
|
||||||
|
|
||||||
Log.init(this) // Init logs in all entry points
|
|
||||||
Log.d(TAG, "Create $this")
|
|
||||||
|
|
||||||
// Dependencies that depend on Context
|
|
||||||
dispatcher = NotificationDispatcher(this, repository)
|
|
||||||
appBaseUrl = getString(R.string.app_base_url)
|
|
||||||
|
|
||||||
// Action bar
|
|
||||||
val toolbarLayout = findViewById<AppBarLayout>(R.id.app_bar_drawer)
|
|
||||||
val dynamicColors = repository.getDynamicColorsEnabled()
|
|
||||||
val darkMode = isDarkThemeOn(this)
|
|
||||||
val statusBarColor = Colors.statusBarNormal(this, dynamicColors, darkMode)
|
|
||||||
val toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode)
|
|
||||||
toolbarLayout.setBackgroundColor(statusBarColor)
|
|
||||||
|
|
||||||
val toolbar = toolbarLayout.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
|
|
||||||
toolbar.setTitleTextColor(toolbarTextColor)
|
|
||||||
toolbar.setNavigationIconTint(toolbarTextColor)
|
|
||||||
toolbar.overflowIcon?.setTint(toolbarTextColor)
|
|
||||||
setSupportActionBar(toolbar)
|
|
||||||
title = getString(R.string.main_action_bar_title)
|
|
||||||
|
|
||||||
// Set system status bar appearance
|
|
||||||
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars =
|
|
||||||
Colors.shouldUseLightStatusBar(dynamicColors, darkMode)
|
|
||||||
|
|
||||||
// Floating action button ("+")
|
|
||||||
fab = findViewById(R.id.fab)
|
|
||||||
fab.setOnClickListener {
|
|
||||||
onSubscribeButtonClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add bottom padding to FAB to account for navigation bar
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets ->
|
|
||||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
||||||
val layoutParams = view.layoutParams as androidx.constraintlayout.widget.ConstraintLayout.LayoutParams
|
|
||||||
layoutParams.bottomMargin = systemBars.bottom
|
|
||||||
view.layoutParams = layoutParams
|
|
||||||
insets
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update main list based on viewModel (& its datasource/livedata)
|
|
||||||
val noEntries: View = findViewById(R.id.main_no_subscriptions)
|
|
||||||
val onSubscriptionClick = { s: Subscription -> onSubscriptionItemClick(s) }
|
|
||||||
val onSubscriptionLongClick = { s: Subscription -> onSubscriptionItemLongClick(s) }
|
|
||||||
|
|
||||||
mainList = findViewById(R.id.main_subscriptions_list)
|
|
||||||
adapter = MainAdapter(
|
|
||||||
repository,
|
|
||||||
onSubscriptionClick,
|
|
||||||
onSubscriptionLongClick,
|
|
||||||
ResourcesCompat.getDrawable(resources, R.drawable.ic_circle, theme)!!.apply {
|
|
||||||
setTint(Colors.primary(this@MainActivity))
|
|
||||||
},
|
|
||||||
Colors.onPrimary(this)
|
|
||||||
)
|
|
||||||
mainList.adapter = adapter
|
|
||||||
|
|
||||||
// Apply window insets to ensure content is not covered by navigation bar
|
|
||||||
mainList.clipToPadding = false
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(mainList) { v, insets ->
|
|
||||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
||||||
v.updatePadding(bottom = systemBars.bottom)
|
|
||||||
insets
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.list().observe(this) {
|
|
||||||
it?.let { subscriptions ->
|
|
||||||
// Update main list
|
|
||||||
adapter.submitList(subscriptions as MutableList<Subscription>)
|
|
||||||
if (it.isEmpty()) {
|
|
||||||
mainList.visibility = View.GONE
|
|
||||||
noEntries.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
mainList.visibility = View.VISIBLE
|
|
||||||
noEntries.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add scrub terms to log (in case it gets exported)
|
|
||||||
subscriptions.forEach { s ->
|
|
||||||
Log.addScrubTerm(shortUrl(s.baseUrl), Log.TermType.Domain)
|
|
||||||
Log.addScrubTerm(s.topic)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update battery banner
|
|
||||||
showHideBatteryBanner(subscriptions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Scrub terms for last topics // FIXME this should be in Log.getFormatted
|
|
||||||
repository.getLastShareTopics().forEach { topicUrl ->
|
|
||||||
maybeSplitTopicUrl(topicUrl)?.let {
|
|
||||||
Log.addScrubTerm(shortUrl(it.first), Log.TermType.Domain)
|
|
||||||
Log.addScrubTerm(shortUrl(it.second), Log.TermType.Term)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// React to changes in instant delivery setting
|
|
||||||
viewModel.listIdsWithInstantStatus().observe(this) {
|
|
||||||
// Signal pushes to us, no service to refresh
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe connection details and update menu item visibility
|
|
||||||
repository.getConnectionDetailsLiveData().observe(this) { details ->
|
|
||||||
showHideConnectionErrorMenuItem(details)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Battery banner
|
|
||||||
val batteryBanner = findViewById<View>(R.id.main_banner_battery) // Banner visibility is toggled in onResume()
|
|
||||||
val dontAskAgainButton = findViewById<Button>(R.id.main_banner_battery_dontaskagain)
|
|
||||||
val askLaterButton = findViewById<Button>(R.id.main_banner_battery_ask_later)
|
|
||||||
val fixNowButton = findViewById<Button>(R.id.main_banner_battery_fix_now)
|
|
||||||
dontAskAgainButton.setOnClickListener {
|
|
||||||
batteryBanner.visibility = View.GONE
|
|
||||||
repository.setBatteryOptimizationsRemindTime(Repository.BATTERY_OPTIMIZATIONS_REMIND_TIME_NEVER)
|
|
||||||
}
|
|
||||||
askLaterButton.setOnClickListener {
|
|
||||||
batteryBanner.visibility = View.GONE
|
|
||||||
repository.setBatteryOptimizationsRemindTime(System.currentTimeMillis() + ONE_DAY_MILLIS)
|
|
||||||
}
|
|
||||||
fixNowButton.setOnClickListener {
|
|
||||||
try {
|
|
||||||
Log.d(TAG, "package:$packageName".toUri().toString())
|
|
||||||
startActivity(
|
|
||||||
Intent(
|
|
||||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
|
||||||
"package:$packageName".toUri()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
try {
|
|
||||||
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
startActivity(Intent(Settings.ACTION_SETTINGS))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Hide, at least for now
|
|
||||||
val batteryBanner = findViewById<View>(R.id.main_banner_battery)
|
|
||||||
batteryBanner.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Hide links that lead to payments, see https://github.com/binwiederhier/ntfy/issues/1463
|
|
||||||
val howToLink = findViewById<TextView>(R.id.main_how_to_link)
|
|
||||||
howToLink.isVisible = BuildConfig.PAYMENT_LINKS_AVAILABLE
|
|
||||||
|
|
||||||
// Create notification channels right away, so we can configure them immediately after installing the app
|
|
||||||
dispatcher?.init()
|
|
||||||
|
|
||||||
// Signal pushes to us, no Firebase to subscribe to
|
|
||||||
|
|
||||||
// Darrkkkk mode
|
|
||||||
AppCompatDelegate.setDefaultNightMode(repository.getDarkMode())
|
|
||||||
|
|
||||||
// Background things
|
|
||||||
schedulePeriodicServiceRestartWorker()
|
|
||||||
|
|
||||||
// Permissions
|
|
||||||
maybeRequestNotificationPermission()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeRequestNotificationPermission() {
|
|
||||||
// Android 13 (SDK 33) requires that we ask for permission to post notifications
|
|
||||||
// https://developer.android.com/develop/ui/views/notifications/notification-permission
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_DENIED) {
|
|
||||||
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
showHideNotificationMenuItems()
|
|
||||||
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
|
|
||||||
redrawList()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showHideBatteryBanner(subscriptions: List<Subscription>) {
|
|
||||||
val batteryRemindTimeReached = repository.getBatteryOptimizationsRemindTime() < System.currentTimeMillis()
|
|
||||||
val ignoringOptimizations = isIgnoringBatteryOptimizations(this@MainActivity)
|
|
||||||
val showBanner = batteryRemindTimeReached && !ignoringOptimizations
|
|
||||||
val batteryBanner = findViewById<View>(R.id.main_banner_battery)
|
|
||||||
batteryBanner.visibility = if (showBanner) View.VISIBLE else View.GONE
|
|
||||||
Log.d(TAG, "Battery: ignoring optimizations = $ignoringOptimizations (we want this to be true); remind time reached = $batteryRemindTimeReached; banner = $showBanner")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun schedulePeriodicServiceRestartWorker() {
|
|
||||||
// Service restart worker not needed for Signal-based implementation
|
|
||||||
Log.d(TAG, "ServiceStartWorker: Not scheduling (using Signal push notifications)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.menu_main_action_bar, menu)
|
|
||||||
this.menu = menu
|
|
||||||
|
|
||||||
// Tint menu icons based on theme
|
|
||||||
val toolbarTextColor = Colors.toolbarTextColor(this, repository.getDynamicColorsEnabled(), isDarkThemeOn(this))
|
|
||||||
for (i in 0 until menu.size) {
|
|
||||||
menu[i].icon?.setTint(toolbarTextColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
showHideNotificationMenuItems()
|
|
||||||
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
|
|
||||||
checkSubscriptionsMuted() // This is done here, because then we know that we've initialized the menu
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkSubscriptionsMuted(delayMillis: Long = 0L) {
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
delay(delayMillis) // Just to be sure we've initialized all the things, we wait a bit ...
|
|
||||||
Log.d(TAG, "Checking global and subscription-specific 'muted until' timestamp")
|
|
||||||
|
|
||||||
// Check global
|
|
||||||
val changed = repository.checkGlobalMutedUntil()
|
|
||||||
if (changed) {
|
|
||||||
Log.d(TAG, "Global muted until timestamp expired; updating prefs")
|
|
||||||
showHideNotificationMenuItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check subscriptions
|
|
||||||
var rerenderList = false
|
|
||||||
repository.getSubscriptions().forEach { subscription ->
|
|
||||||
val mutedUntilExpired = subscription.mutedUntil > 1L && System.currentTimeMillis()/1000 > subscription.mutedUntil
|
|
||||||
if (mutedUntilExpired) {
|
|
||||||
Log.d(TAG, "Subscription ${subscription.id}: Muted until timestamp expired, updating subscription")
|
|
||||||
val newSubscription = subscription.copy(mutedUntil = 0L)
|
|
||||||
repository.updateSubscription(newSubscription)
|
|
||||||
rerenderList = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rerenderList) {
|
|
||||||
mainList.post {
|
|
||||||
redrawList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showHideNotificationMenuItems() {
|
|
||||||
if (!this::menu.isInitialized) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val mutedUntilSeconds = repository.getGlobalMutedUntil()
|
|
||||||
runOnUiThread {
|
|
||||||
// Show/hide menu items based on build config
|
|
||||||
val rateAppItem = menu.findItem(R.id.main_menu_rate)
|
|
||||||
val docsItem = menu.findItem(R.id.main_menu_docs)
|
|
||||||
val reportBugItem = menu.findItem(R.id.main_menu_report_bug)
|
|
||||||
rateAppItem.isVisible = BuildConfig.RATE_APP_AVAILABLE
|
|
||||||
docsItem.isVisible = BuildConfig.PAYMENT_LINKS_AVAILABLE // Google Payments Policy, see https://github.com/binwiederhier/ntfy/issues/1463
|
|
||||||
reportBugItem.isVisible = BuildConfig.PAYMENT_LINKS_AVAILABLE // Google Payments Policy, see https://github.com/binwiederhier/ntfy/issues/1463
|
|
||||||
|
|
||||||
// Pause notification icons
|
|
||||||
val notificationsEnabledItem = menu.findItem(R.id.main_menu_notifications_enabled)
|
|
||||||
val notificationsDisabledUntilItem = menu.findItem(R.id.main_menu_notifications_disabled_until)
|
|
||||||
val notificationsDisabledForeverItem = menu.findItem(R.id.main_menu_notifications_disabled_forever)
|
|
||||||
notificationsEnabledItem?.isVisible = mutedUntilSeconds == 0L
|
|
||||||
notificationsDisabledForeverItem?.isVisible = mutedUntilSeconds == 1L
|
|
||||||
notificationsDisabledUntilItem?.isVisible = mutedUntilSeconds > 1L
|
|
||||||
if (mutedUntilSeconds > 1L) {
|
|
||||||
val formattedDate = formatDateShort(mutedUntilSeconds)
|
|
||||||
notificationsDisabledUntilItem?.title = getString(R.string.main_menu_notifications_disabled_until, formattedDate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showHideConnectionErrorMenuItem(details: Map<String, com.lonecloud.sup.db.ConnectionDetails>) {
|
|
||||||
if (!this::menu.isInitialized) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runOnUiThread {
|
|
||||||
val connectionErrorItem = menu.findItem(R.id.main_menu_connection_error)
|
|
||||||
val hasErrors = details.values.any { it.hasError() }
|
|
||||||
connectionErrorItem?.isVisible = hasErrors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
return when (item.itemId) {
|
|
||||||
R.id.main_menu_notifications_enabled -> {
|
|
||||||
onNotificationSettingsClick(enable = false)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
R.id.main_menu_notifications_disabled_forever -> {
|
|
||||||
onNotificationSettingsClick(enable = true)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
R.id.main_menu_notifications_disabled_until -> {
|
|
||||||
onNotificationSettingsClick(enable = true)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
R.id.main_menu_connection_error -> {
|
|
||||||
onConnectionErrorClick()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
R.id.main_menu_settings -> {
|
|
||||||
// Settings activity not implemented
|
|
||||||
Log.d(TAG, "Settings not available")
|
|
||||||
true
|
|
||||||
}
|
|
||||||
R.id.main_menu_report_bug -> {
|
|
||||||
startActivity(
|
|
||||||
Intent(Intent.ACTION_VIEW, getString(R.string.main_menu_report_bug_url).toUri())
|
|
||||||
)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
R.id.main_menu_rate -> {
|
|
||||||
try {
|
|
||||||
startActivity(
|
|
||||||
Intent(Intent.ACTION_VIEW, "market://details?id=$packageName".toUri())
|
|
||||||
)
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
startActivity(
|
|
||||||
Intent(Intent.ACTION_VIEW, "https://play.google.com/store/apps/details?id=$packageName".toUri())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
R.id.main_menu_docs -> {
|
|
||||||
startActivity(
|
|
||||||
Intent(Intent.ACTION_VIEW, getString(R.string.main_menu_docs_url).toUri())
|
|
||||||
)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onNotificationSettingsClick(enable: Boolean) {
|
|
||||||
if (!enable) {
|
|
||||||
Log.d(TAG, "Notification settings dialog not available")
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Re-enabling global notifications")
|
|
||||||
onNotificationMutedUntilChanged(Repository.MUTED_UNTIL_SHOW_ALL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onConnectionErrorClick() {
|
|
||||||
Log.d(TAG, "Connection error dialog not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
|
|
||||||
repository.setGlobalMutedUntil(mutedUntilTimestamp)
|
|
||||||
showHideNotificationMenuItems()
|
|
||||||
runOnUiThread {
|
|
||||||
redrawList() // Update the "muted until" icons
|
|
||||||
when (mutedUntilTimestamp) {
|
|
||||||
0L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show()
|
|
||||||
1L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_LONG).show()
|
|
||||||
else -> {
|
|
||||||
val formattedDate = formatDateShort(mutedUntilTimestamp)
|
|
||||||
Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_muted_until_toast_message, formattedDate), Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onSubscribeButtonClick() {
|
|
||||||
val newFragment = AddFragment()
|
|
||||||
newFragment.show(supportFragmentManager, AddFragment.TAG)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSubscribe(topic: String, baseUrl: String, instant: Boolean) {
|
|
||||||
Log.d(TAG, "Adding subscription ${topicShortUrl(baseUrl, topic)} (instant = $instant)")
|
|
||||||
|
|
||||||
// Add subscription to database
|
|
||||||
val subscription = Subscription(
|
|
||||||
id = randomSubscriptionId(),
|
|
||||||
baseUrl = baseUrl,
|
|
||||||
topic = topic,
|
|
||||||
mutedUntil = 0,
|
|
||||||
minPriority = Repository.MIN_PRIORITY_USE_GLOBAL,
|
|
||||||
autoDelete = Repository.AUTO_DELETE_USE_GLOBAL,
|
|
||||||
insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL,
|
|
||||||
upAppId = null,
|
|
||||||
upConnectorToken = null,
|
|
||||||
displayName = null,
|
|
||||||
totalCount = 0,
|
|
||||||
newCount = 0,
|
|
||||||
lastActive = Date().time/1000
|
|
||||||
)
|
|
||||||
viewModel.add(subscription)
|
|
||||||
|
|
||||||
// Signal pushes to us, no Firebase to subscribe to
|
|
||||||
|
|
||||||
// Fetch cached messages
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
|
|
||||||
notifications.forEach { notification ->
|
|
||||||
repository.addNotification(notification)
|
|
||||||
// Icon download not implemented
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Unable to fetch notifications: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch to detail view after adding it
|
|
||||||
onSubscriptionItemClick(subscription)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onSubscriptionItemClick(subscription: Subscription) {
|
|
||||||
if (actionMode != null) {
|
|
||||||
handleActionModeClick(subscription)
|
|
||||||
} else if (subscription.upAppId != null) {
|
|
||||||
startDetailSettingsView(subscription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onSubscriptionItemLongClick(subscription: Subscription) {
|
|
||||||
if (actionMode == null) {
|
|
||||||
beginActionMode(subscription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private fun startDetailSettingsView(subscription: Subscription) {
|
|
||||||
Log.d(TAG, "Opening subscription settings for ${topicShortUrl(subscription.baseUrl, subscription.topic)}")
|
|
||||||
|
|
||||||
// Detail settings removed
|
|
||||||
// val intent = Intent(this, DetailSettingsActivity::class.java)
|
|
||||||
// intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
|
|
||||||
// intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
|
|
||||||
// intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
|
|
||||||
// intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(appBaseUrl, subscription))
|
|
||||||
// startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleActionModeClick(subscription: Subscription) {
|
|
||||||
adapter.toggleSelection(subscription.id)
|
|
||||||
if (adapter.selected.size == 0) {
|
|
||||||
finishActionMode()
|
|
||||||
} else {
|
|
||||||
actionMode!!.title = adapter.selected.size.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onMultiDeleteClick() {
|
|
||||||
Log.d(TAG, "Showing multi-delete dialog for selected items")
|
|
||||||
|
|
||||||
val dialog = MaterialAlertDialogBuilder(this)
|
|
||||||
.setMessage(R.string.main_action_mode_delete_dialog_message)
|
|
||||||
.setPositiveButton(R.string.main_action_mode_delete_dialog_permanently_delete) { _, _ ->
|
|
||||||
adapter.selected.map { subscriptionId -> viewModel.remove(this, subscriptionId) }
|
|
||||||
finishActionMode()
|
|
||||||
}
|
|
||||||
.setNegativeButton(R.string.main_action_mode_delete_dialog_cancel) { _, _ ->
|
|
||||||
finishActionMode()
|
|
||||||
}
|
|
||||||
.create()
|
|
||||||
dialog.setOnShowListener {
|
|
||||||
dialog
|
|
||||||
.getButton(AlertDialog.BUTTON_POSITIVE)
|
|
||||||
.dangerButton()
|
|
||||||
}
|
|
||||||
dialog.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun beginActionMode(subscription: Subscription) {
|
|
||||||
actionMode = startSupportActionMode(actionModeCallback)
|
|
||||||
adapter.toggleSelection(subscription.id)
|
|
||||||
|
|
||||||
// Fade out FAB
|
|
||||||
fab.alpha = 1f
|
|
||||||
fab
|
|
||||||
.animate()
|
|
||||||
.alpha(0f)
|
|
||||||
.setDuration(ANIMATION_DURATION)
|
|
||||||
.setListener(object : AnimatorListenerAdapter() {
|
|
||||||
override fun onAnimationEnd(animation: Animator) {
|
|
||||||
fab.visibility = View.GONE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun finishActionMode() {
|
|
||||||
actionMode!!.finish()
|
|
||||||
endActionModeAndRedraw()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun endActionModeAndRedraw() {
|
|
||||||
actionMode = null
|
|
||||||
adapter.selected.clear()
|
|
||||||
redrawList()
|
|
||||||
|
|
||||||
// Fade in FAB
|
|
||||||
fab.alpha = 0f
|
|
||||||
fab.visibility = View.VISIBLE
|
|
||||||
fab
|
|
||||||
.animate()
|
|
||||||
.alpha(1f)
|
|
||||||
.setDuration(ANIMATION_DURATION)
|
|
||||||
.setListener(object : AnimatorListenerAdapter() {
|
|
||||||
override fun onAnimationEnd(animation: Animator) {
|
|
||||||
fab.visibility = View.VISIBLE // Required to replace the old listener
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun redrawList() {
|
|
||||||
if (!this::mainList.isInitialized) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
adapter.notifyItemRangeChanged(0, adapter.currentList.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val TAG = "NtfyMainActivity"
|
|
||||||
const val EXTRA_SUBSCRIPTION_ID = "subscriptionId"
|
|
||||||
const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl"
|
|
||||||
const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic"
|
|
||||||
const val EXTRA_SUBSCRIPTION_DISPLAY_NAME = "subscriptionDisplayName"
|
|
||||||
const val EXTRA_SUBSCRIPTION_MUTED_UNTIL = "subscriptionMutedUntil"
|
|
||||||
const val ANIMATION_DURATION = 80L
|
|
||||||
const val ONE_DAY_MILLIS = 86400000L
|
|
||||||
|
|
||||||
// As per documentation: The minimum repeat interval that can be defined is 15 minutes
|
|
||||||
// (same as the JobScheduler API), but in practice 15 doesn't work. Using 16 here.
|
|
||||||
// Thanks to varunon9 (https://gist.github.com/varunon9/f2beec0a743c96708eb0ef971a9ff9cd) for this!
|
|
||||||
|
|
||||||
const val POLL_WORKER_INTERVAL_MINUTES = 60L
|
|
||||||
const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
package com.lonecloud.sup.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.lonecloud.sup.BuildConfig
|
|
||||||
import com.lonecloud.sup.R
|
|
||||||
import com.lonecloud.sup.db.ConnectionState
|
|
||||||
import com.lonecloud.sup.db.Repository
|
|
||||||
import com.lonecloud.sup.db.Subscription
|
|
||||||
import com.lonecloud.sup.util.displayName
|
|
||||||
import com.lonecloud.sup.util.readBitmapFromUriOrNull
|
|
||||||
import java.text.DateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class MainAdapter(
|
|
||||||
private val repository: Repository,
|
|
||||||
private val onClick: (Subscription) -> Unit,
|
|
||||||
private val onLongClick: (Subscription) -> Unit,
|
|
||||||
private val countDrawable: Drawable,
|
|
||||||
private val onPrimaryColor: Int
|
|
||||||
) :
|
|
||||||
ListAdapter<Subscription, MainAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
|
|
||||||
val selected = mutableSetOf<Long>() // Subscription IDs
|
|
||||||
|
|
||||||
/* Creates and inflates view and return TopicViewHolder. */
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
|
|
||||||
val view = LayoutInflater.from(parent.context)
|
|
||||||
.inflate(R.layout.fragment_main_item, parent, false)
|
|
||||||
return SubscriptionViewHolder(view, repository, selected, onClick, onLongClick, countDrawable, onPrimaryColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gets current topic and uses it to bind view. */
|
|
||||||
override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) {
|
|
||||||
val subscription = getItem(position)
|
|
||||||
holder.bind(subscription)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleSelection(subscriptionId: Long) {
|
|
||||||
if (selected.contains(subscriptionId)) {
|
|
||||||
selected.remove(subscriptionId)
|
|
||||||
} else {
|
|
||||||
selected.add(subscriptionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected.isNotEmpty()) {
|
|
||||||
val listIds = currentList.map { subscription -> subscription.id }
|
|
||||||
val subscriptionPosition = listIds.indexOf(subscriptionId)
|
|
||||||
notifyItemChanged(subscriptionPosition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
|
|
||||||
class SubscriptionViewHolder(
|
|
||||||
itemView: View,
|
|
||||||
private val repository: Repository,
|
|
||||||
private val selected: Set<Long>,
|
|
||||||
val onClick: (Subscription) -> Unit,
|
|
||||||
val onLongClick: (Subscription) -> Unit,
|
|
||||||
private val countDrawable: Drawable,
|
|
||||||
private val onPrimaryColor: Int
|
|
||||||
) :
|
|
||||||
RecyclerView.ViewHolder(itemView) {
|
|
||||||
private var subscription: Subscription? = null
|
|
||||||
private val context: Context = itemView.context
|
|
||||||
private val imageView: ImageView = itemView.findViewById(R.id.main_item_image)
|
|
||||||
private val nameView: TextView = itemView.findViewById(R.id.main_item_text)
|
|
||||||
private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
|
|
||||||
private val dateView: TextView = itemView.findViewById(R.id.main_item_date)
|
|
||||||
private val connectionErrorImageView: View = itemView.findViewById(R.id.main_item_connection_error_image)
|
|
||||||
private val notificationDisabledUntilImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_until_image)
|
|
||||||
private val notificationDisabledForeverImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_forever_image)
|
|
||||||
private val instantImageView: View = itemView.findViewById(R.id.main_item_instant_image)
|
|
||||||
private val newItemsView: TextView = itemView.findViewById(R.id.main_item_new)
|
|
||||||
private val appBaseUrl = context.getString(R.string.app_base_url)
|
|
||||||
|
|
||||||
fun bind(subscription: Subscription) {
|
|
||||||
this.subscription = subscription
|
|
||||||
val isUnifiedPush = subscription.upAppId != null
|
|
||||||
var statusMessage = if (isUnifiedPush) {
|
|
||||||
context.getString(R.string.main_item_status_unified_push, subscription.upAppId)
|
|
||||||
} else if (subscription.totalCount == 1) {
|
|
||||||
context.getString(R.string.main_item_status_text_one, subscription.totalCount)
|
|
||||||
} else {
|
|
||||||
context.getString(R.string.main_item_status_text_not_one, subscription.totalCount)
|
|
||||||
}
|
|
||||||
if (subscription.connectionDetails.state == ConnectionState.CONNECTING) {
|
|
||||||
statusMessage += ", " + context.getString(R.string.main_item_status_reconnecting)
|
|
||||||
}
|
|
||||||
val date = Date(subscription.lastActive * 1000)
|
|
||||||
val dateStr = DateFormat.getDateInstance(DateFormat.SHORT).format(date)
|
|
||||||
val moreThanOneDay = System.currentTimeMillis()/1000 - subscription.lastActive > 24 * 60 * 60
|
|
||||||
val sameDay = dateStr == DateFormat.getDateInstance(DateFormat.SHORT).format(Date()) // Omg this is horrible
|
|
||||||
val dateText = if (subscription.lastActive == 0L) {
|
|
||||||
""
|
|
||||||
} else if (sameDay) {
|
|
||||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(date)
|
|
||||||
} else if (!moreThanOneDay) {
|
|
||||||
context.getString(R.string.main_item_date_yesterday)
|
|
||||||
} else {
|
|
||||||
dateStr
|
|
||||||
}
|
|
||||||
val globalMutedUntil = repository.getGlobalMutedUntil()
|
|
||||||
val showMutedForeverIcon = (subscription.mutedUntil == 1L || globalMutedUntil == 1L) && !isUnifiedPush
|
|
||||||
val showMutedUntilIcon = !showMutedForeverIcon && (subscription.mutedUntil > 1L || globalMutedUntil > 1L) && !isUnifiedPush
|
|
||||||
imageView.setImageResource(R.drawable.ic_sms_gray_24dp)
|
|
||||||
nameView.text = displayName(appBaseUrl, subscription)
|
|
||||||
statusView.text = statusMessage
|
|
||||||
dateView.text = dateText
|
|
||||||
dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE
|
|
||||||
val showConnectionError = subscription.connectionDetails.hasError()
|
|
||||||
connectionErrorImageView.visibility = if (showConnectionError) View.VISIBLE else View.GONE
|
|
||||||
notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE
|
|
||||||
notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE
|
|
||||||
if (isUnifiedPush || subscription.newCount == 0) {
|
|
||||||
newItemsView.visibility = View.GONE
|
|
||||||
} else {
|
|
||||||
newItemsView.visibility = View.VISIBLE
|
|
||||||
newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+"
|
|
||||||
newItemsView.setTextColor(onPrimaryColor)
|
|
||||||
newItemsView.background = countDrawable
|
|
||||||
}
|
|
||||||
itemView.setOnClickListener { onClick(subscription) }
|
|
||||||
itemView.setOnLongClickListener { onLongClick(subscription); true }
|
|
||||||
if (selected.contains(subscription.id)) {
|
|
||||||
itemView.setBackgroundColor(Colors.itemSelectedBackground(context))
|
|
||||||
} else {
|
|
||||||
itemView.setBackgroundColor(Color.TRANSPARENT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object TopicDiffCallback : DiffUtil.ItemCallback<Subscription>() {
|
|
||||||
override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
|
||||||
return oldItem.id == newItem.id
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
|
||||||
return oldItem == newItem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val TAG = "NtfyMainAdapter"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
package com.lonecloud.sup.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.lonecloud.sup.R
|
|
||||||
import com.lonecloud.sup.db.*
|
|
||||||
import com.lonecloud.sup.up.Distributor
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
|
|
||||||
class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
|
|
||||||
fun list(): LiveData<List<Subscription>> {
|
|
||||||
return repository.getSubscriptionsLiveData()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun listIdsWithInstantStatus(): LiveData<Set<Pair<Long, Boolean>>> {
|
|
||||||
return repository.getSubscriptionIdsWithInstantStatusLiveData()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
repository.addSubscription(subscription)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun remove(context: Context, subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
val subscription = repository.getSubscription(subscriptionId) ?: return@launch
|
|
||||||
if (subscription.upAppId != null && subscription.upConnectorToken != null) {
|
|
||||||
val distributor = Distributor(context)
|
|
||||||
distributor.sendUnregistered(subscription.upAppId, subscription.upConnectorToken)
|
|
||||||
}
|
|
||||||
repository.removeAllNotifications(subscriptionId)
|
|
||||||
repository.removeSubscription(subscriptionId)
|
|
||||||
// Firebase unsubscribe removed
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun get(baseUrl: String, topic: String): Subscription? {
|
|
||||||
return repository.getSubscription(baseUrl, topic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SubscriptionsViewModelFactory(private val repository: Repository) : ViewModelProvider.Factory {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
|
||||||
with(modelClass){
|
|
||||||
when {
|
|
||||||
isAssignableFrom(SubscriptionsViewModel::class.java) -> SubscriptionsViewModel(repository) as T
|
|
||||||
else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
package com.lonecloud.sup.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ArrayAdapter
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.TextView
|
|
||||||
import com.lonecloud.sup.R
|
|
||||||
|
|
||||||
data class PriorityItem(
|
|
||||||
val priority: Int,
|
|
||||||
val label: String,
|
|
||||||
val iconResId: Int
|
|
||||||
) {
|
|
||||||
override fun toString(): String = label
|
|
||||||
}
|
|
||||||
|
|
||||||
class PriorityAdapter(
|
|
||||||
context: Context,
|
|
||||||
private val items: List<PriorityItem>
|
|
||||||
) : ArrayAdapter<PriorityItem>(context, R.layout.item_priority_dropdown, items) {
|
|
||||||
|
|
||||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
|
||||||
return createItemView(position, convertView, parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
|
||||||
return createItemView(position, convertView, parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createItemView(position: Int, convertView: View?, parent: ViewGroup): View {
|
|
||||||
val view = convertView ?: LayoutInflater.from(context)
|
|
||||||
.inflate(R.layout.item_priority_dropdown, parent, false)
|
|
||||||
|
|
||||||
val item = items[position]
|
|
||||||
val iconView = view.findViewById<ImageView>(R.id.priority_icon)
|
|
||||||
val textView = view.findViewById<TextView>(R.id.priority_text)
|
|
||||||
|
|
||||||
iconView.setImageResource(item.iconResId)
|
|
||||||
textView.text = item.label
|
|
||||||
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun createPriorityItems(context: Context): List<PriorityItem> {
|
|
||||||
return listOf(
|
|
||||||
PriorityItem(5, context.getString(R.string.common_priority_max_name), R.drawable.ic_priority_5_24dp),
|
|
||||||
PriorityItem(4, context.getString(R.string.common_priority_high_name), R.drawable.ic_priority_4_24dp),
|
|
||||||
PriorityItem(3, context.getString(R.string.common_priority_default_name), R.drawable.ic_priority_3_24dp),
|
|
||||||
PriorityItem(2, context.getString(R.string.common_priority_low_name), R.drawable.ic_priority_2_24dp),
|
|
||||||
PriorityItem(1, context.getString(R.string.common_priority_min_name), R.drawable.ic_priority_1_24dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
package com.lonecloud.sup.up
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import com.lonecloud.sup.R
|
|
||||||
import com.lonecloud.sup.app.Application
|
|
||||||
import com.lonecloud.sup.db.Repository
|
|
||||||
import com.lonecloud.sup.db.Subscription
|
|
||||||
import com.lonecloud.sup.util.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is the UnifiedPush broadcast receiver to handle the distributor actions REGISTER and UNREGISTER.
|
|
||||||
* See https://unifiedpush.org/spec/android/ for details.
|
|
||||||
*/
|
|
||||||
class BroadcastReceiver : android.content.BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
if (context == null || intent == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Log.init(context) // Init in all entrypoints
|
|
||||||
when (intent.action) {
|
|
||||||
ACTION_REGISTER -> register(context, intent)
|
|
||||||
ACTION_UNREGISTER -> unregister(context, intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun register(context: Context, intent: Intent) {
|
|
||||||
val appId = getApplication(context, intent) ?: return
|
|
||||||
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return
|
|
||||||
val app = context.applicationContext as Application
|
|
||||||
val repository = app.repository
|
|
||||||
val distributor = Distributor(app)
|
|
||||||
Log.d(TAG, "REGISTER received for app $appId (connectorToken=$connectorToken)")
|
|
||||||
if (!repository.getUnifiedPushEnabled()) {
|
|
||||||
Log.w(TAG, "Refusing registration because 'EnableUP' is disabled")
|
|
||||||
// Action required: tell the app to not try again before an action as be done manuall by the user
|
|
||||||
distributor.sendRegistrationFailed(appId, connectorToken, FailedReason.ACTION_REQUIRED)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (appId.isBlank()) {
|
|
||||||
Log.w(TAG, "Refusing registration: Empty application")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
// We're doing all of this inside a critical section, because of possible races.
|
|
||||||
// See https://github.com/binwiederhier/ntfy/issues/230 for details.
|
|
||||||
|
|
||||||
mutex.withLock {
|
|
||||||
val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
|
|
||||||
if (existingSubscription != null) {
|
|
||||||
if (existingSubscription.upAppId == appId) {
|
|
||||||
val endpoint = topicUrlUp(existingSubscription.baseUrl, existingSubscription.topic)
|
|
||||||
Log.d(TAG, "Subscription with connectorToken $connectorToken exists. Sending endpoint $endpoint.")
|
|
||||||
distributor.sendEndpoint(appId, connectorToken, endpoint)
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Subscription with connectorToken $connectorToken exists for a different app. Refusing registration.")
|
|
||||||
// Internal_error: try again with a new token
|
|
||||||
distributor.sendRegistrationFailed(appId, connectorToken, FailedReason.INTERNAL_ERROR)
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add subscription
|
|
||||||
val baseUrl = repository.getDefaultBaseUrl() ?: context.getString(R.string.app_base_url)
|
|
||||||
val topic = UP_PREFIX + randomString(TOPIC_RANDOM_ID_LENGTH)
|
|
||||||
val endpoint = topicUrlUp(baseUrl, topic)
|
|
||||||
val subscription = Subscription(
|
|
||||||
id = randomSubscriptionId(),
|
|
||||||
baseUrl = baseUrl,
|
|
||||||
topic = topic,
|
|
||||||
mutedUntil = 0,
|
|
||||||
minPriority = Repository.MIN_PRIORITY_USE_GLOBAL,
|
|
||||||
autoDelete = Repository.AUTO_DELETE_USE_GLOBAL,
|
|
||||||
insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL,
|
|
||||||
upAppId = appId,
|
|
||||||
upConnectorToken = connectorToken,
|
|
||||||
displayName = null,
|
|
||||||
totalCount = 0,
|
|
||||||
newCount = 0,
|
|
||||||
lastActive = Date().time/1000
|
|
||||||
)
|
|
||||||
Log.d(TAG, "Adding subscription with for app $appId (connectorToken $connectorToken): $subscription")
|
|
||||||
try {
|
|
||||||
// Note, this may fail due to a SQL constraint exception, see https://github.com/binwiederhier/ntfy/issues/185
|
|
||||||
repository.addSubscription(subscription)
|
|
||||||
distributor.sendEndpoint(appId, connectorToken, endpoint)
|
|
||||||
|
|
||||||
// Signal pushes to us, no service to refresh
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to add subscription", e)
|
|
||||||
// Try again when there is network
|
|
||||||
distributor.sendRegistrationFailed(appId, connectorToken, FailedReason.NETWORK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to log scrubber
|
|
||||||
Log.addScrubTerm(shortUrl(baseUrl), Log.TermType.Domain)
|
|
||||||
Log.addScrubTerm(topic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get application package name
|
|
||||||
*/
|
|
||||||
private fun getApplication(context: Context, intent: Intent): String? {
|
|
||||||
return getApplicationAnd3(context, intent)
|
|
||||||
?: getApplicationAnd2(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get application package name following AND_3 specifications.
|
|
||||||
*/
|
|
||||||
private fun getApplicationAnd3(context: Context, intent: Intent): String? {
|
|
||||||
return if (Build.VERSION.SDK_INT >= 34) {
|
|
||||||
getApplicationAnd3SharedId(context, intent)
|
|
||||||
} else {
|
|
||||||
getApplicationAnd3PendingIntent(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try get application package name following AND_3 specifications for SDK>=34, with the shared
|
|
||||||
* identity.
|
|
||||||
*
|
|
||||||
* It fallback to [getApplicationAnd3PendingIntent] if the other application targets SDK<34.
|
|
||||||
*/
|
|
||||||
@RequiresApi(34)
|
|
||||||
private fun getApplicationAnd3SharedId(context: Context, intent: Intent): String? {
|
|
||||||
return sentFromPackage?.also {
|
|
||||||
// We got the package name with the shared identity
|
|
||||||
android.util.Log.d(TAG, "Registering $it. Package name retrieved with shared identity")
|
|
||||||
} ?: getApplicationAnd3PendingIntent(intent)?.let { packageId ->
|
|
||||||
// We got the package name with the pending intent, checking if that app targets SDK<34
|
|
||||||
return if (Build.VERSION.SDK_INT >= 33) {
|
|
||||||
context.packageManager.getApplicationInfo(
|
|
||||||
packageId,
|
|
||||||
PackageManager.ApplicationInfoFlags.of(
|
|
||||||
PackageManager.GET_META_DATA.toLong()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
context.packageManager.getApplicationInfo(packageId, 0)
|
|
||||||
}.let { ai ->
|
|
||||||
if (ai.targetSdkVersion >= 34) {
|
|
||||||
android.util.Log.d(TAG, "App targeting Sdk >= 34 without shared identity, ignoring.")
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
packageId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try get application package name following AND_3 specifications when running on
|
|
||||||
* a device with SDK<34 or receiving message from an application targeting SDK<34, with a pending
|
|
||||||
* intent.
|
|
||||||
*
|
|
||||||
* Always prefer [getApplicationAnd3SharedId] if possible.
|
|
||||||
*/
|
|
||||||
private fun getApplicationAnd3PendingIntent(intent: Intent): String? {
|
|
||||||
return intent.getParcelableExtra<PendingIntent>(EXTRA_PI)?.creatorPackage?.also {
|
|
||||||
android.util.Log.d(TAG, "Registering $it. Package name retrieved with PendingIntent")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try get the application package name using AND_2 specifications
|
|
||||||
*/
|
|
||||||
@Deprecated("This follows AND_2 specifications. Will be removed.")
|
|
||||||
private fun getApplicationAnd2(intent: Intent): String? {
|
|
||||||
return intent.getStringExtra(EXTRA_APPLICATION)?.also {
|
|
||||||
android.util.Log.d(TAG, "Registering $it. Package name retrieved with legacy String extra")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unregister(context: Context, intent: Intent) {
|
|
||||||
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return
|
|
||||||
val app = context.applicationContext as Application
|
|
||||||
val repository = app.repository
|
|
||||||
val distributor = Distributor(app)
|
|
||||||
Log.d(TAG, "UNREGISTER received (connectorToken=$connectorToken)")
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
// We're doing all of this inside a critical section, because of possible races.
|
|
||||||
// See https://github.com/binwiederhier/ntfy/issues/230 for details.
|
|
||||||
|
|
||||||
mutex.withLock {
|
|
||||||
val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
|
|
||||||
if (existingSubscription == null) {
|
|
||||||
Log.d(TAG, "Subscription with connectorToken $connectorToken does not exist. Ignoring.")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove subscription
|
|
||||||
Log.d(TAG, "Removing subscription ${existingSubscription.id} with connectorToken $connectorToken")
|
|
||||||
repository.removeSubscription(existingSubscription.id)
|
|
||||||
existingSubscription.upAppId?.let { appId -> distributor.sendUnregistered(appId, connectorToken) }
|
|
||||||
|
|
||||||
// Signal pushes to us, no service to refresh
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "NtfyUpBroadcastRecv"
|
|
||||||
private const val UP_PREFIX = "up"
|
|
||||||
private const val TOPIC_RANDOM_ID_LENGTH = 12
|
|
||||||
|
|
||||||
val mutex = Mutex() // https://github.com/binwiederhier/ntfy/issues/230
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package com.lonecloud.sup.up
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constants as defined on the specs
|
|
||||||
* https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md
|
|
||||||
*/
|
|
||||||
|
|
||||||
const val ACTION_NEW_ENDPOINT = "org.unifiedpush.android.connector.NEW_ENDPOINT"
|
|
||||||
const val ACTION_REGISTRATION_FAILED = "org.unifiedpush.android.connector.REGISTRATION_FAILED"
|
|
||||||
const val ACTION_UNREGISTERED = "org.unifiedpush.android.connector.UNREGISTERED"
|
|
||||||
const val ACTION_MESSAGE = "org.unifiedpush.android.connector.MESSAGE"
|
|
||||||
|
|
||||||
const val ACTION_REGISTER = "org.unifiedpush.android.distributor.REGISTER"
|
|
||||||
const val ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER"
|
|
||||||
|
|
||||||
const val EXTRA_APPLICATION = "application"
|
|
||||||
const val EXTRA_PI = "pi"
|
|
||||||
const val EXTRA_TOKEN = "token"
|
|
||||||
const val EXTRA_ENDPOINT = "endpoint"
|
|
||||||
const val EXTRA_FAILED_REASON = "reason"
|
|
||||||
const val EXTRA_BYTES_MESSAGE = "bytesMessage"
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
package com.lonecloud.sup.up
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import com.lonecloud.sup.util.Log
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is the UnifiedPush distributor, an amalgamation of messages to be sent as part of the spec.
|
|
||||||
* See https://unifiedpush.org/spec/android/ for details.
|
|
||||||
*/
|
|
||||||
class Distributor(val context: Context) {
|
|
||||||
fun sendMessage(app: String, connectorToken: String, message: ByteArray) {
|
|
||||||
Log.d(TAG, "Sending MESSAGE to $app (token=$connectorToken): ${message.size} bytes")
|
|
||||||
RaiseAppToForegroundFactory
|
|
||||||
.getInstance(context, app)
|
|
||||||
.raiseAndSend(connectorToken, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendEndpoint(app: String, connectorToken: String, endpoint: String) {
|
|
||||||
Log.d(TAG, "Sending NEW_ENDPOINT to $app (token=$connectorToken): $endpoint")
|
|
||||||
val broadcastIntent = Intent()
|
|
||||||
broadcastIntent.`package` = app
|
|
||||||
broadcastIntent.action = ACTION_NEW_ENDPOINT
|
|
||||||
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
|
|
||||||
broadcastIntent.putExtra(EXTRA_ENDPOINT, endpoint)
|
|
||||||
context.sendBroadcast(broadcastIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendUnregistered(app: String, connectorToken: String) {
|
|
||||||
Log.d(TAG, "Sending UNREGISTERED to $app (token=$connectorToken)")
|
|
||||||
val broadcastIntent = Intent()
|
|
||||||
broadcastIntent.`package` = app
|
|
||||||
broadcastIntent.action = ACTION_UNREGISTERED
|
|
||||||
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
|
|
||||||
context.sendBroadcast(broadcastIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendRegistrationFailed(app: String, connectorToken: String, reason: FailedReason) {
|
|
||||||
Log.d(TAG, "Sending REGISTRATION_FAILED to $app (token=$connectorToken)")
|
|
||||||
val broadcastIntent = Intent()
|
|
||||||
broadcastIntent.`package` = app
|
|
||||||
broadcastIntent.action = ACTION_REGISTRATION_FAILED
|
|
||||||
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
|
|
||||||
broadcastIntent.putExtra(EXTRA_FAILED_REASON, reason)
|
|
||||||
context.sendBroadcast(broadcastIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "NtfyUpDistributor"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
package com.lonecloud.sup.up
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A registration request may fail for different reasons.
|
|
||||||
*/
|
|
||||||
enum class FailedReason {
|
|
||||||
/**
|
|
||||||
* This is a generic error type, you can try to register again directly.
|
|
||||||
*/
|
|
||||||
INTERNAL_ERROR,
|
|
||||||
/**
|
|
||||||
* The registration failed because of missing network connection, try again when network is back.
|
|
||||||
*/
|
|
||||||
NETWORK,
|
|
||||||
/**
|
|
||||||
* The distributor requires a user action to work. For instance, the distributor may be log out of the push server and requires the user to log in. The user must interact with the distributor or sending a new registration will fail again.
|
|
||||||
*/
|
|
||||||
ACTION_REQUIRED,
|
|
||||||
/**
|
|
||||||
* The distributor requires a VAPID key and the app didn't provide one during registration.
|
|
||||||
*/
|
|
||||||
VAPID_REQUIRED, // Currently unused
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
package com.lonecloud.sup.up
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This implements the "Select default distributor" selection for UnifiedPush.
|
|
||||||
*
|
|
||||||
* To test, install ntfy and another distributor (e.g. SunUp) on the same phone.
|
|
||||||
* Install an app that uses UnifiedPush (e.g. UP Example) and click "Register".
|
|
||||||
*
|
|
||||||
* You should see a popup to select the default distributor.
|
|
||||||
* See https://unifiedpush.org/developers/spec/android/#link-activity
|
|
||||||
*/
|
|
||||||
class LinkActivity: Activity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
intent?.data?.run {
|
|
||||||
Log.d(TAG, "Received request for $callingPackage")
|
|
||||||
val intent = Intent("org.unifiedpush.register.dummy_app")
|
|
||||||
val pendingIntent = PendingIntent.getBroadcast(this@LinkActivity, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
|
||||||
val result = Intent().apply {
|
|
||||||
putExtra(EXTRA_PI, pendingIntent)
|
|
||||||
}
|
|
||||||
setResult(RESULT_OK, result)
|
|
||||||
} ?: setResult(RESULT_CANCELED)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "NtfyUpLinkActivity"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
package com.lonecloud.sup.up
|
|
||||||
|
|
||||||
import android.app.ActivityManager
|
|
||||||
import android.app.ActivityManager.RunningAppProcessInfo
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.ServiceConnection
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.coroutines.Runnable
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
import java.util.concurrent.ScheduledFuture
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raises the target application to foreground importance before delivering a UnifiedPush message.
|
|
||||||
*
|
|
||||||
* ## Background
|
|
||||||
*
|
|
||||||
* Starting with Android 12 (API level 31), apps running in the background are restricted from
|
|
||||||
* starting foreground services. This is problematic for push notification scenarios where the
|
|
||||||
* target app (e.g., a messenger like Element, Molly, etc.) needs to start a foreground service
|
|
||||||
* to process incoming messages reliably.
|
|
||||||
*
|
|
||||||
* See: https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start
|
|
||||||
*
|
|
||||||
* ## How This Works
|
|
||||||
*
|
|
||||||
* This class implements the **AND_3 specification** from UnifiedPush, which defines a mechanism
|
|
||||||
* for distributors (like ntfy) to "raise" the target application to foreground importance before
|
|
||||||
* sending the push message.
|
|
||||||
*
|
|
||||||
* See: https://unifiedpush.org/developers/spec/android/#service-to-raise-to-the-foreground
|
|
||||||
*
|
|
||||||
* Here's the flow:
|
|
||||||
*
|
|
||||||
* 1. **Check ntfy's foreground status**: When ntfy receives a message for a UnifiedPush app,
|
|
||||||
* it temporarily has foreground importance (IMPORTANCE_FOREGROUND or IMPORTANCE_FOREGROUND_SERVICE).
|
|
||||||
* The [checkForeground] method verifies this.
|
|
||||||
*
|
|
||||||
* 2. **Check target app support**: The target app must export a service with the action
|
|
||||||
* `org.unifiedpush.android.connector.RAISE_TO_FOREGROUND`. This is checked via
|
|
||||||
* [hasRaiseToForegroundService].
|
|
||||||
*
|
|
||||||
* 3. **Bind to target's service**: If both conditions are met, ntfy binds to the target app's
|
|
||||||
* "raise to foreground" service using [Context.bindService] with [Context.BIND_AUTO_CREATE].
|
|
||||||
* **This is the key mechanism**: when a foreground process binds to a service, the Android
|
|
||||||
* system grants foreground importance to the bound service's process. This allows the target
|
|
||||||
* app to escape background restrictions and start its own foreground service if needed.
|
|
||||||
*
|
|
||||||
* 4. **Send the message**: Once bound (or immediately if binding isn't possible), the push
|
|
||||||
* message is delivered via a broadcast with action `org.unifiedpush.android.connector.MESSAGE`.
|
|
||||||
*
|
|
||||||
* 5. **Maintain binding briefly**: The binding is kept alive for 5 seconds (or extended if more
|
|
||||||
* messages arrive) to give the target app time to start its foreground service. After the
|
|
||||||
* timeout, the binding is released via [unbind].
|
|
||||||
*
|
|
||||||
* ## Example logs (using UP-Example app, see https://github.com/binwiederhier/ntfy-android/pull/98#issuecomment-3681330111)
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* NtfyUpDistributor D Sending MESSAGE to org.unifiedpush.example (token=e9830034-b97d-485d-a7f1-c8a03af9cbd2): 107 bytes
|
|
||||||
* NtfyUpRaiseFgFactory D New instance for org.unifiedpush.example
|
|
||||||
* NtfyUpRaiseFg I Found foreground process: io.heckel.ntfy.debug
|
|
||||||
* NtfyUpRaiseFg D Binding to org.unifiedpush.example
|
|
||||||
* NtfyUpRaiseFg D onServiceConnected ComponentInfo{org.unifiedpush.example/org.unifiedpush.android.connector.internal.RaiseToForegroundService}
|
|
||||||
* NtfyUpRaiseFg D Sending msg for org.unifiedpush.example
|
|
||||||
* NtfyUpRaiseFg D Timeout expired, unbinding
|
|
||||||
* NtfyUpRaiseFgFactory D Removing instance for org.unifiedpush.example
|
|
||||||
* NtfyUpRaiseFg D Unbound
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* ## Why "Foreground" Matters
|
|
||||||
*
|
|
||||||
* "Foreground" in this context does NOT mean the app is visible on screen (alt-tab style).
|
|
||||||
* Rather, it refers to the app's **process importance level** as tracked by the Android system.
|
|
||||||
* An app with foreground importance:
|
|
||||||
* - Is exempt from doze mode restrictions
|
|
||||||
* - Can start foreground services from the background
|
|
||||||
* - Has higher priority and is less likely to be killed
|
|
||||||
*
|
|
||||||
* This mechanism allows ntfy to temporarily "lend" its foreground importance to the target app,
|
|
||||||
* enabling reliable push notification processing even when both apps are in the background.
|
|
||||||
*
|
|
||||||
* ## Fallback Behavior
|
|
||||||
*
|
|
||||||
* If ntfy isn't in the foreground or the target app doesn't support the raise-to-foreground
|
|
||||||
* service, the message is sent directly via broadcast without the binding step. This maintains
|
|
||||||
* backward compatibility with older apps but may result in delayed or missed notifications
|
|
||||||
* on devices with aggressive battery optimization.
|
|
||||||
*
|
|
||||||
* ## Connection Lifecycle
|
|
||||||
*
|
|
||||||
* - [Bound.Unbound]: No active connection. Will attempt to bind on next message.
|
|
||||||
* - [Bound.Binding]: Binding in progress. Messages are queued until connected.
|
|
||||||
* - [Bound.Bound]: Connected. Messages are sent immediately.
|
|
||||||
*
|
|
||||||
* The connection auto-unbinds after 5 seconds of inactivity. Subsequent messages reset this timer.
|
|
||||||
*
|
|
||||||
* @param context The application context for binding services and sending broadcasts.
|
|
||||||
* @param app The package name of the target application.
|
|
||||||
* @param onUnbound Callback invoked when the service connection is unbound (used by
|
|
||||||
* [RaiseAppToForegroundFactory] for cleanup).
|
|
||||||
*
|
|
||||||
* @see RaiseAppToForegroundFactory For singleton management of instances per app.
|
|
||||||
* @see Distributor.sendMessage Entry point for sending UnifiedPush messages.
|
|
||||||
*/
|
|
||||||
class RaiseAppToForeground(private val context: Context, private val app: String, private val onUnbound: () -> Unit) : ServiceConnection, Runnable {
|
|
||||||
class Message(val token: String, val content: ByteArray)
|
|
||||||
|
|
||||||
private enum class Bound {
|
|
||||||
Binding,
|
|
||||||
Bound,
|
|
||||||
Unbound
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the service bound ? This is a per service connection
|
|
||||||
*/
|
|
||||||
private var bound = Bound.Unbound
|
|
||||||
private var scheduledFuture: ScheduledFuture<*>? = null
|
|
||||||
private val msgsQueue = mutableListOf<Message>()
|
|
||||||
|
|
||||||
private val foregroundImportance = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
listOf(
|
|
||||||
RunningAppProcessInfo.IMPORTANCE_FOREGROUND,
|
|
||||||
RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
listOf(
|
|
||||||
RunningAppProcessInfo.IMPORTANCE_FOREGROUND,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raise [app] to the foreground, to follow AND_3 specifications
|
|
||||||
*
|
|
||||||
* @return `true` if have successfully raised app to foreground
|
|
||||||
*/
|
|
||||||
fun raiseAndSend(token: String, message: ByteArray): Boolean {
|
|
||||||
val msg = Message(token, message)
|
|
||||||
// Per instance lock
|
|
||||||
synchronized(this) {
|
|
||||||
when (bound) {
|
|
||||||
Bound.Bound -> {
|
|
||||||
Log.d(TAG, "Service connection already bound to ${this.app}")
|
|
||||||
delayUnbinding()
|
|
||||||
send(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
Bound.Binding -> {
|
|
||||||
delayUnbinding()
|
|
||||||
queue(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
Bound.Unbound -> {
|
|
||||||
val isForeground = checkForeground()
|
|
||||||
val targetHasService = hasRaiseToForegroundService()
|
|
||||||
if (isForeground && targetHasService) {
|
|
||||||
bind()
|
|
||||||
queue(msg)
|
|
||||||
} else {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Cannot raise to foreground: isForeground=$isForeground, targetHasService=$targetHasService"
|
|
||||||
)
|
|
||||||
send(msg)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return `true` if the app is in Foreground importance
|
|
||||||
*/
|
|
||||||
private fun checkForeground(): Boolean {
|
|
||||||
val appProcesses = (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).runningAppProcesses
|
|
||||||
for (appProcess in appProcesses) {
|
|
||||||
if (appProcess.importance in foregroundImportance) {
|
|
||||||
Log.i(TAG, "Found foreground process: ${appProcess.processName}")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasRaiseToForegroundService(): Boolean {
|
|
||||||
val intent = Intent(ACTION).apply {
|
|
||||||
`package` = app
|
|
||||||
}
|
|
||||||
return (if (Build.VERSION.SDK_INT >= 33) {
|
|
||||||
context.packageManager.queryIntentServices(
|
|
||||||
intent,
|
|
||||||
PackageManager.ResolveInfoFlags.of(
|
|
||||||
PackageManager.GET_META_DATA.toLong() +
|
|
||||||
PackageManager.GET_RESOLVED_FILTER.toLong(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
context.packageManager.queryIntentServices(
|
|
||||||
Intent(ACTION_REGISTER),
|
|
||||||
PackageManager.GET_RESOLVED_FILTER,
|
|
||||||
)
|
|
||||||
}).any {
|
|
||||||
it.serviceInfo.exported
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun send(message: Message) {
|
|
||||||
Log.d(TAG, "Sending msg for $app")
|
|
||||||
val broadcastIntent = Intent()
|
|
||||||
broadcastIntent.`package` = app
|
|
||||||
broadcastIntent.action = ACTION_MESSAGE
|
|
||||||
broadcastIntent.putExtra(EXTRA_TOKEN, message.token)
|
|
||||||
broadcastIntent.putExtra(EXTRA_BYTES_MESSAGE, message.content)
|
|
||||||
context.sendBroadcast(broadcastIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Queue message when the service is binding
|
|
||||||
*/
|
|
||||||
private fun queue(message: Message) {
|
|
||||||
msgsQueue.add(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If the service is already bound, we delay its unbinding
|
|
||||||
*/
|
|
||||||
private fun delayUnbinding() {
|
|
||||||
/**
|
|
||||||
* Close current scheduledFuture. We interrupt if it is running (mayInterruptIfRunning = true), so [run] won't
|
|
||||||
* unbind this new connection after we release the lock.
|
|
||||||
*/
|
|
||||||
scheduledFuture?.cancel(true)
|
|
||||||
/** Call [run] (unbind) in 5 seconds */
|
|
||||||
scheduledFuture = unbindExecutor.schedule(this, 5L, TimeUnit.SECONDS)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bind() {
|
|
||||||
Log.d(TAG, "Binding to ${this.app}")
|
|
||||||
val intent = Intent().apply {
|
|
||||||
`package` = this@RaiseAppToForeground.app
|
|
||||||
action = ACTION
|
|
||||||
}
|
|
||||||
/** Bind to the target raise to the foreground service */
|
|
||||||
context.bindService(intent, this, Context.BIND_AUTO_CREATE)
|
|
||||||
/** Call [run] (unbind) in 5 seconds */
|
|
||||||
scheduledFuture = unbindExecutor.schedule(this, 5L, TimeUnit.SECONDS)
|
|
||||||
bound = Bound.Binding
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unbind() {
|
|
||||||
// Per instance lock
|
|
||||||
synchronized(this) {
|
|
||||||
if (bound != Bound.Unbound) {
|
|
||||||
msgsQueue.clear()
|
|
||||||
context.unbindService(this)
|
|
||||||
bound = Bound.Unbound
|
|
||||||
onUnbound()
|
|
||||||
Log.d(TAG, "Unbound")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
|
||||||
Log.d(TAG, "onServiceConnected $name")
|
|
||||||
synchronized(this) {
|
|
||||||
bound = Bound.Bound
|
|
||||||
}
|
|
||||||
msgsQueue.forEach { msg ->
|
|
||||||
send(msg)
|
|
||||||
}
|
|
||||||
msgsQueue.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceDisconnected(name: ComponentName?) {
|
|
||||||
Log.d(TAG, "onServiceDisconnected $name")
|
|
||||||
unbind()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindingDied(name: ComponentName?) {
|
|
||||||
Log.d(TAG, "onBindingDied")
|
|
||||||
unbind()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNullBinding(name: ComponentName?) {
|
|
||||||
Log.d(TAG, "onBindingDied")
|
|
||||||
unbind()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unbinding when the timeout passes.
|
|
||||||
*/
|
|
||||||
override fun run() {
|
|
||||||
Log.d(TAG, "Timeout expired, unbinding")
|
|
||||||
unbind()
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
private const val TAG = "NtfyUpRaiseFg"
|
|
||||||
private const val ACTION = "org.unifiedpush.android.connector.RAISE_TO_FOREGROUND"
|
|
||||||
|
|
||||||
/** Executor to unbind 5 seconds later */
|
|
||||||
private val unbindExecutor = Executors.newSingleThreadScheduledExecutor()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
package com.lonecloud.sup.up
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [RaiseAppToForeground] Factory, to avoid having one service connection per push message
|
|
||||||
*
|
|
||||||
* There is a very small chance that 2 connections exist at the same time\*, but that's not important.
|
|
||||||
* We just want to avoid tens of it.
|
|
||||||
*
|
|
||||||
* \* When [getInstance] returns an existing instance, that runs [remove] before
|
|
||||||
* [RaiseAppToForeground.raiseAndSend] is called.
|
|
||||||
*/
|
|
||||||
object RaiseAppToForegroundFactory {
|
|
||||||
fun getInstance(context: Context, app: String): RaiseAppToForeground {
|
|
||||||
synchronized(this) {
|
|
||||||
return instances[app] ?: new(context, app)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun new(context: Context, app: String): RaiseAppToForeground {
|
|
||||||
return RaiseAppToForeground(context, app, onUnbound = {
|
|
||||||
remove(app)
|
|
||||||
}).also {
|
|
||||||
Log.d(TAG, "New instance for $app")
|
|
||||||
instances[app] = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun remove(app: String) {
|
|
||||||
Log.d(TAG, "Removing instance for $app")
|
|
||||||
synchronized(this) {
|
|
||||||
instances.remove(app)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private val instances: MutableMap<String, RaiseAppToForeground> = mutableMapOf()
|
|
||||||
private const val TAG = "NtfyUpRaiseFgFactory"
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
package com.lonecloud.sup.util
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Base64
|
|
||||||
import com.lonecloud.sup.db.Repository
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.net.URL
|
|
||||||
import java.security.KeyStore
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.security.cert.CertificateException
|
|
||||||
import java.security.cert.CertificateFactory
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.net.ssl.KeyManager
|
|
||||||
import javax.net.ssl.KeyManagerFactory
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLException
|
|
||||||
import javax.net.ssl.SSLSocket
|
|
||||||
import javax.net.ssl.TrustManager
|
|
||||||
import javax.net.ssl.TrustManagerFactory
|
|
||||||
import javax.net.ssl.HostnameVerifier
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TLS config:
|
|
||||||
* - For each baseUrl, either use the pinned certificate (if one exists) OR system trust
|
|
||||||
* - Pinned cert = ONLY that exact certificate is trusted (strict pinning)
|
|
||||||
* - Hostname verification is bypassed for pinned certificates (the fingerprint match is the trust anchor)
|
|
||||||
* - Optional mTLS via per-baseUrl PKCS#12 client cert
|
|
||||||
*/
|
|
||||||
class CertUtil private constructor(context: Context) {
|
|
||||||
private val appContext: Context = context.applicationContext
|
|
||||||
private val repository: Repository by lazy { Repository.getInstance(appContext) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure OkHttp client with TLS config using system trust.
|
|
||||||
*/
|
|
||||||
suspend fun withTLSConfig(builder: OkHttpClient.Builder, baseUrl: String): OkHttpClient.Builder {
|
|
||||||
// Using system trust only - custom certificates not supported
|
|
||||||
return builder
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the server certificate without trusting it.
|
|
||||||
* Used to display certificate details before user decides to trust.
|
|
||||||
*/
|
|
||||||
fun fetchServerCertificate(baseUrl: String): X509Certificate? {
|
|
||||||
// Certificate fetching not implemented
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "NtfyCertUtil"
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
|
||||||
private var instance: CertUtil? = null
|
|
||||||
|
|
||||||
fun getInstance(context: Context): CertUtil =
|
|
||||||
instance ?: synchronized(this) { instance ?: CertUtil(context).also { instance = it } }
|
|
||||||
|
|
||||||
fun calculateFingerprint(cert: X509Certificate): String {
|
|
||||||
val md = MessageDigest.getInstance("SHA-256")
|
|
||||||
val digest = md.digest(cert.encoded)
|
|
||||||
return digest.joinToString(":") { "%02X".format(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parsePemCertificate(pem: String): X509Certificate {
|
|
||||||
val factory = CertificateFactory.getInstance("X.509")
|
|
||||||
return factory.generateCertificate(pem.byteInputStream()) as X509Certificate
|
|
||||||
}
|
|
||||||
|
|
||||||
fun encodeCertificateToPem(cert: X509Certificate): String {
|
|
||||||
val base64 = Base64.encodeToString(cert.encoded, Base64.NO_WRAP)
|
|
||||||
return buildString {
|
|
||||||
append("-----BEGIN CERTIFICATE-----\n")
|
|
||||||
var i = 0
|
|
||||||
while (i < base64.length) {
|
|
||||||
val end = minOf(i + 64, base64.length)
|
|
||||||
append(base64.substring(i, end))
|
|
||||||
append("\n")
|
|
||||||
i += 64
|
|
||||||
}
|
|
||||||
append("-----END CERTIFICATE-----")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parsePkcs12Certificate(p12Base64: String, password: String): X509Certificate {
|
|
||||||
val p12Data = Base64.decode(p12Base64, Base64.DEFAULT)
|
|
||||||
val keyStore = KeyStore.getInstance("PKCS12")
|
|
||||||
ByteArrayInputStream(p12Data).use { keyStore.load(it, password.toCharArray()) }
|
|
||||||
val alias = keyStore.aliases().nextElement()
|
|
||||||
return keyStore.getCertificate(alias) as X509Certificate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
package com.lonecloud.sup.util
|
|
||||||
|
|
||||||
const val ANDROID_APP_MIME_TYPE = "application/vnd.android.package-archive"
|
|
||||||
|
|
||||||
const val PRIORITY_MIN = 1
|
|
||||||
const val PRIORITY_LOW = 2
|
|
||||||
const val PRIORITY_DEFAULT = 3
|
|
||||||
const val PRIORITY_HIGH = 4
|
|
||||||
const val PRIORITY_MAX = 5
|
|
||||||
|
|
||||||
val ALL_PRIORITIES = listOf(PRIORITY_MIN, PRIORITY_LOW, PRIORITY_DEFAULT, PRIORITY_HIGH, PRIORITY_MAX)
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
package com.lonecloud.sup.util
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import com.lonecloud.sup.BuildConfig
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility class for creating OkHttpClient instances and Request builders.
|
|
||||||
* All clients are configured with SSL/TLS settings from CertUtil for custom certificate support.
|
|
||||||
*/
|
|
||||||
object HttpUtil {
|
|
||||||
val USER_AGENT = "ntfy/${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}; Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT})"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client for regular API calls (auth, poll, etc.).
|
|
||||||
*/
|
|
||||||
suspend fun defaultClient(context: Context, baseUrl: String): OkHttpClient {
|
|
||||||
return defaultClientBuilder(context, baseUrl).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client with a longer call timeout (5 minutes).
|
|
||||||
* Allows for large file uploads or downloads.
|
|
||||||
*/
|
|
||||||
suspend fun longCallClient(context: Context, baseUrl: String): OkHttpClient {
|
|
||||||
return defaultClientBuilder(context, baseUrl)
|
|
||||||
.callTimeout(5, TimeUnit.MINUTES)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client for long-polling/streaming subscriptions.
|
|
||||||
*/
|
|
||||||
suspend fun subscriberClient(context: Context, baseUrl: String): OkHttpClient {
|
|
||||||
return emptyClientBuilder(context, baseUrl)
|
|
||||||
.readTimeout(77, TimeUnit.SECONDS) // Long enough to allow for server-side keepalive messages
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client for WebSocket connections.
|
|
||||||
* No read timeout, 1 minute ping interval, 10s connect timeout.
|
|
||||||
*/
|
|
||||||
suspend fun wsClient(context: Context, baseUrl: String): OkHttpClient {
|
|
||||||
return emptyClientBuilder(context, baseUrl)
|
|
||||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
|
||||||
.pingInterval(1, TimeUnit.MINUTES) // Technically not necessary, the server also pings us
|
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requestBuilder(url: String): Request.Builder {
|
|
||||||
return Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.addHeader("User-Agent", USER_AGENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun emptyClientBuilder(context: Context, baseUrl: String): OkHttpClient.Builder {
|
|
||||||
return CertUtil
|
|
||||||
.getInstance(context)
|
|
||||||
.withTLSConfig(OkHttpClient.Builder(), baseUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun defaultClientBuilder(context: Context, baseUrl: String): OkHttpClient.Builder {
|
|
||||||
return emptyClientBuilder(context, baseUrl)
|
|
||||||
.callTimeout(1, TimeUnit.MINUTES) // Increased to 1min (from 15s) to reduce client variance
|
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(15, TimeUnit.SECONDS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
package com.lonecloud.sup.util
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import com.lonecloud.sup.BuildConfig
|
|
||||||
import com.lonecloud.sup.db.Database
|
|
||||||
import com.lonecloud.sup.db.LogDao
|
|
||||||
import com.lonecloud.sup.db.LogEntry
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
|
|
||||||
class Log(private val logsDao: LogDao) {
|
|
||||||
private val record: AtomicBoolean = AtomicBoolean(false)
|
|
||||||
private val count: AtomicInteger = AtomicInteger(0)
|
|
||||||
private val scrubNum: AtomicInteger = AtomicInteger(-1)
|
|
||||||
private val scrubTerms = Collections.synchronizedMap(mutableMapOf<String, ReplaceTerm>())
|
|
||||||
|
|
||||||
private fun log(level: Int, tag: String, message: String, exception: Throwable?) {
|
|
||||||
if (!record.get()) return
|
|
||||||
GlobalScope.launch(Dispatchers.IO) { // FIXME This does not guarantee the log order
|
|
||||||
logsDao.insert(LogEntry(System.currentTimeMillis(), tag, level, message, exception?.stackTraceToString()))
|
|
||||||
val current = count.incrementAndGet()
|
|
||||||
if (current >= PRUNE_EVERY) {
|
|
||||||
logsDao.prune(ENTRIES_MAX)
|
|
||||||
count.set(0) // I know there is a race here, but this is good enough
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFormatted(context: Context, scrub: Boolean): String {
|
|
||||||
val logs = formatEntries(if (scrub) scrubEntries(logsDao.getAll()) else logsDao.getAll())
|
|
||||||
val settings = "" // Settings backup removed
|
|
||||||
return prependDeviceInfo(logs, settings, scrubLine = scrub)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun prependDeviceInfo(logs: String, settings: String, scrubLine: Boolean): String {
|
|
||||||
val maybeScrubLine = if (scrubLine) "Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑.\n" else ""
|
|
||||||
return """
|
|
||||||
This is a log of the ntfy Android app. The log shows up to 1,000 entries.
|
|
||||||
$maybeScrubLine
|
|
||||||
Device info:
|
|
||||||
--
|
|
||||||
ntfy: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR})
|
|
||||||
OS: ${System.getProperty("os.version")}
|
|
||||||
Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})
|
|
||||||
Model: ${Build.DEVICE}
|
|
||||||
Product: ${Build.PRODUCT}
|
|
||||||
|
|
||||||
--
|
|
||||||
Settings:
|
|
||||||
""".trimIndent() + "\n$settings\n\nLogs\n--\n\n$logs"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addScrubTerm(term: String, type: TermType = TermType.Term) {
|
|
||||||
if (scrubTerms[term] != null || IGNORE_TERMS.contains(term)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (type == TermType.Password) {
|
|
||||||
scrubTerms[term] = ReplaceTerm(type, "********")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val replaceTermIndex = scrubNum.incrementAndGet()
|
|
||||||
val replaceTerm = REPLACE_TERMS.getOrNull(replaceTermIndex) ?: "fruit${replaceTermIndex}"
|
|
||||||
scrubTerms[term] = ReplaceTerm(type, when (type) {
|
|
||||||
TermType.Domain -> "$replaceTerm.example.com"
|
|
||||||
TermType.Username -> "${replaceTerm}user"
|
|
||||||
else -> replaceTerm
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scrubEntries(entries: List<LogEntry>): List<LogEntry> {
|
|
||||||
return entries
|
|
||||||
.map { e ->
|
|
||||||
e.copy(
|
|
||||||
message = scrub(e.message)!!,
|
|
||||||
exception = scrub(e.exception)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scrub(line: String?): String? {
|
|
||||||
var newLine = line ?: return null
|
|
||||||
scrubTerms.forEach { (scrubTerm, replaceTerm) ->
|
|
||||||
newLine = newLine.replace(scrubTerm, replaceTerm.replaceTerm)
|
|
||||||
}
|
|
||||||
return newLine
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatEntries(entries: List<LogEntry>): String {
|
|
||||||
return entries.joinToString(separator = "\n") { e ->
|
|
||||||
val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").format(Date(e.timestamp))
|
|
||||||
val level = when (e.level) {
|
|
||||||
android.util.Log.DEBUG -> "D"
|
|
||||||
android.util.Log.INFO -> "I"
|
|
||||||
android.util.Log.WARN -> "W"
|
|
||||||
android.util.Log.ERROR -> "E"
|
|
||||||
else -> "?"
|
|
||||||
}
|
|
||||||
val tag = e.tag.format("%23s")
|
|
||||||
val prefix = "${e.timestamp} $date $level $tag"
|
|
||||||
val message = if (e.exception != null) {
|
|
||||||
"${e.message}\nException:\n${e.exception}"
|
|
||||||
} else {
|
|
||||||
e.message
|
|
||||||
}
|
|
||||||
"$prefix $message"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deleteAll() {
|
|
||||||
return logsDao.deleteAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class TermType {
|
|
||||||
Domain, Username, Password, Term
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ReplaceTerm(
|
|
||||||
val termType: TermType,
|
|
||||||
val replaceTerm: String
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "NtfyLog"
|
|
||||||
private const val PRUNE_EVERY = 100
|
|
||||||
private const val ENTRIES_MAX = 1000
|
|
||||||
private val IGNORE_TERMS = listOf("ntfy.sh")
|
|
||||||
private val REPLACE_TERMS = listOf(
|
|
||||||
"banana", "kiwi", "lemon", "coconut", "avocado", "orange", "apple", "peach",
|
|
||||||
"pineapple", "dragonfruit", "durian", "starfruit"
|
|
||||||
)
|
|
||||||
private var instance: Log? = null
|
|
||||||
|
|
||||||
fun d(tag: String, message: String, exception: Throwable? = null) {
|
|
||||||
if (exception == null) android.util.Log.d(tag, message) else android.util.Log.d(tag, message, exception)
|
|
||||||
getInstance()?.log(android.util.Log.DEBUG, tag, message, exception)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun i(tag: String, message: String, exception: Throwable? = null) {
|
|
||||||
if (exception == null) android.util.Log.i(tag, message) else android.util.Log.i(tag, message, exception)
|
|
||||||
getInstance()?.log(android.util.Log.INFO, tag, message, exception)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun w(tag: String, message: String, exception: Throwable? = null) {
|
|
||||||
if (exception == null) android.util.Log.w(tag, message) else android.util.Log.w(tag, message, exception)
|
|
||||||
getInstance()?.log(android.util.Log.WARN, tag, message, exception)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun e(tag: String, message: String, exception: Throwable? = null) {
|
|
||||||
if (exception == null) android.util.Log.e(tag, message) else android.util.Log.e(tag, message, exception)
|
|
||||||
getInstance()?.log(android.util.Log.ERROR, tag, message, exception)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setRecord(enable: Boolean) {
|
|
||||||
if (!enable) d(TAG, "Disabled log recording")
|
|
||||||
getInstance()?.record?.set(enable)
|
|
||||||
if (enable) d(TAG, "Enabled log recording")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getRecord(): Boolean {
|
|
||||||
return getInstance()?.record?.get() ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFormatted(context: Context, scrub: Boolean): String {
|
|
||||||
return getInstance()?.getFormatted(context, scrub) ?: "(no logs)"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getScrubTerms(): Map<String, String> {
|
|
||||||
return getInstance()?.scrubTerms!!
|
|
||||||
.filter { e -> e.value.termType != TermType.Password } // We do not want to display passwords
|
|
||||||
.map { e -> e.key to e.value.replaceTerm }
|
|
||||||
.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteAll() {
|
|
||||||
getInstance()?.deleteAll()
|
|
||||||
d(TAG, "Log was truncated")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addScrubTerm(term: String, type: TermType = TermType.Term) {
|
|
||||||
getInstance()?.addScrubTerm(term, type)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun init(context: Context) {
|
|
||||||
return synchronized(Log::class) {
|
|
||||||
if (instance == null) {
|
|
||||||
val database = Database.getInstance(context.applicationContext)
|
|
||||||
instance = Log(database.logDao())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getInstance(): Log? {
|
|
||||||
return synchronized(Log::class) {
|
|
||||||
instance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
package com.lonecloud.sup.util
|
|
||||||
|
|
||||||
import okhttp3.MediaType
|
|
||||||
import okhttp3.RequestBody
|
|
||||||
import okio.Buffer
|
|
||||||
import okio.BufferedSink
|
|
||||||
import okio.ForwardingSink
|
|
||||||
import okio.buffer
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A RequestBody wrapper that reports upload progress.
|
|
||||||
*/
|
|
||||||
class ProgressRequestBody(
|
|
||||||
private val delegate: RequestBody,
|
|
||||||
private val onProgress: (bytesWritten: Long, totalBytes: Long) -> Unit
|
|
||||||
) : RequestBody() {
|
|
||||||
|
|
||||||
override fun contentType(): MediaType? = delegate.contentType()
|
|
||||||
|
|
||||||
override fun contentLength(): Long = delegate.contentLength()
|
|
||||||
|
|
||||||
override fun writeTo(sink: BufferedSink) {
|
|
||||||
val totalBytes = contentLength()
|
|
||||||
val countingSink = object : ForwardingSink(sink) {
|
|
||||||
var bytesWritten = 0L
|
|
||||||
|
|
||||||
override fun write(source: Buffer, byteCount: Long) {
|
|
||||||
super.write(source, byteCount)
|
|
||||||
bytesWritten += byteCount
|
|
||||||
onProgress(bytesWritten, totalBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val bufferedSink = countingSink.buffer()
|
|
||||||
delegate.writeTo(bufferedSink)
|
|
||||||
bufferedSink.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,456 +0,0 @@
|
||||||
package com.lonecloud.sup.util
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
|
||||||
import android.content.res.Resources
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.drawable.RippleDrawable
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import android.text.Editable
|
|
||||||
import android.text.TextWatcher
|
|
||||||
import android.util.Base64
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.Button
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import com.lonecloud.sup.R
|
|
||||||
import com.lonecloud.sup.db.Notification
|
|
||||||
import com.lonecloud.sup.db.Repository
|
|
||||||
import com.lonecloud.sup.db.Subscription
|
|
||||||
import com.lonecloud.sup.msg.MESSAGE_ENCODING_BASE64
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import okhttp3.MediaType
|
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import okhttp3.RequestBody
|
|
||||||
import okio.BufferedSink
|
|
||||||
import okio.source
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import java.io.IOException
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.text.DateFormat
|
|
||||||
import java.text.StringCharacterIterator
|
|
||||||
import java.util.Date
|
|
||||||
import kotlin.math.abs
|
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
|
|
||||||
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
|
|
||||||
fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // UnifiedPush
|
|
||||||
fun topicUrlJson(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?since=$since"
|
|
||||||
fun topicUrlWs(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/ws?since=$since"
|
|
||||||
fun topicUrlAuth(baseUrl: String, topic: String) = "${topicUrl(baseUrl, topic)}/auth"
|
|
||||||
fun topicUrlJsonPoll(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?poll=1&since=$since"
|
|
||||||
fun topicShortUrl(baseUrl: String, topic: String) = shortUrl(topicUrl(baseUrl, topic))
|
|
||||||
|
|
||||||
fun subscriptionTopicShortUrl(subscription: Subscription) : String {
|
|
||||||
return topicShortUrl(subscription.baseUrl, subscription.topic)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun displayName(appBaseUrl: String?, subscription: Subscription) : String {
|
|
||||||
if (subscription.displayName != null) {
|
|
||||||
return subscription.displayName
|
|
||||||
} else if (appBaseUrl == subscription.baseUrl) {
|
|
||||||
return subscription.topic
|
|
||||||
}
|
|
||||||
return subscriptionTopicShortUrl(subscription)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shortUrl(url: String) = url
|
|
||||||
.replace("http://", "")
|
|
||||||
.replace("https://", "")
|
|
||||||
|
|
||||||
fun extractBaseUrl(url: String): String {
|
|
||||||
val httpUrl = url.toHttpUrlOrNull() ?: return ""
|
|
||||||
val schemeAndHost = "${httpUrl.scheme}://${httpUrl.host}"
|
|
||||||
val maybePort = if (httpUrl.port != 80 && httpUrl.port != 443) ":${httpUrl.port}" else ""
|
|
||||||
return schemeAndHost + maybePort
|
|
||||||
}
|
|
||||||
|
|
||||||
fun splitTopicUrl(topicUrl: String): Pair<String, String> {
|
|
||||||
if (topicUrl.lastIndexOf("/") == -1) throw Exception("Invalid argument $topicUrl")
|
|
||||||
return Pair(topicUrl.substringBeforeLast("/"), topicUrl.substringAfterLast("/"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun maybeSplitTopicUrl(topicUrl: String): Pair<String, String>? {
|
|
||||||
return try {
|
|
||||||
splitTopicUrl(topicUrl)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validTopic(topic: String): Boolean {
|
|
||||||
return "[-_A-Za-z0-9]{1,64}".toRegex().matches(topic) // Must match server side!
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validUrl(url: String): Boolean {
|
|
||||||
return "^https?://\\S+".toRegex().matches(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun formatDateShort(timestampSecs: Long): String {
|
|
||||||
val date = Date(timestampSecs*1000)
|
|
||||||
return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toPriority(priority: Int?): Int {
|
|
||||||
return if (priority != null && ALL_PRIORITIES.contains(priority)) priority else PRIORITY_DEFAULT
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toPriorityString(context: Context, priority: Int): String {
|
|
||||||
return when (priority) {
|
|
||||||
PRIORITY_MIN -> context.getString(R.string.settings_notifications_priority_min)
|
|
||||||
PRIORITY_LOW -> context.getString(R.string.settings_notifications_priority_low)
|
|
||||||
PRIORITY_DEFAULT -> context.getString(R.string.settings_notifications_priority_default)
|
|
||||||
PRIORITY_HIGH -> context.getString(R.string.settings_notifications_priority_high)
|
|
||||||
PRIORITY_MAX -> context.getString(R.string.settings_notifications_priority_max)
|
|
||||||
else -> context.getString(R.string.settings_notifications_priority_default)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun joinTags(tags: List<String>?): String {
|
|
||||||
return tags?.joinToString(",") ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fun joinTagsMap(tags: List<String>?): String {
|
|
||||||
return tags?.mapIndexed { i, tag -> "${i+1}=${tag}" }?.joinToString(",") ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fun splitTags(tags: String?): List<String> {
|
|
||||||
return if (tags == null || tags == "") {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
tags.split(",")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toEmojis(tags: List<String>): List<String> {
|
|
||||||
return tags.mapNotNull { tag -> toEmoji(tag) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toEmoji(tag: String): String? {
|
|
||||||
// EmojiManager removed - no emoji support
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun unmatchedTags(tags: List<String>): List<String> {
|
|
||||||
// All tags are unmatched without EmojiManager
|
|
||||||
return tags
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepend tags/emojis to message, but only if there is a non-empty title.
|
|
||||||
* Otherwise, the tags will be prepended to the title.
|
|
||||||
*/
|
|
||||||
fun formatMessage(notification: Notification): String {
|
|
||||||
return if (notification.title != "") {
|
|
||||||
decodeMessage(notification)
|
|
||||||
} else {
|
|
||||||
val emojis = toEmojis(splitTags(notification.tags))
|
|
||||||
if (emojis.isEmpty()) {
|
|
||||||
decodeMessage(notification)
|
|
||||||
} else {
|
|
||||||
emojis.joinToString("") + " " + decodeMessage(notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decodeMessage(notification: Notification): String {
|
|
||||||
return notification.message
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decodeBytesMessage(notification: Notification): ByteArray {
|
|
||||||
return notification.message.toByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* See above; prepend emojis to title if the title is non-empty.
|
|
||||||
* Otherwise, they are prepended to the message.
|
|
||||||
*/
|
|
||||||
fun formatTitle(appBaseUrl: String?, subscription: Subscription, notification: Notification): String {
|
|
||||||
return if (notification.title != "") {
|
|
||||||
formatTitle(notification)
|
|
||||||
} else {
|
|
||||||
displayName(appBaseUrl, subscription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun formatTitle(notification: Notification): String {
|
|
||||||
val emojis = toEmojis(splitTags(notification.tags))
|
|
||||||
return if (emojis.isEmpty()) {
|
|
||||||
notification.title
|
|
||||||
} else {
|
|
||||||
emojis.joinToString("") + " " + notification.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Queries the filename of a content URI
|
|
||||||
fun fileName(context: Context, contentUri: String?, fallbackName: String): String {
|
|
||||||
return try {
|
|
||||||
val info = fileStat(context, contentUri?.toUri())
|
|
||||||
info.filename
|
|
||||||
} catch (_: Exception) {
|
|
||||||
fallbackName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fileStat(context: Context, contentUri: Uri?): FileInfo {
|
|
||||||
if (contentUri == null) {
|
|
||||||
throw FileNotFoundException("URI is null")
|
|
||||||
}
|
|
||||||
val resolver = context.applicationContext.contentResolver
|
|
||||||
val cursor = resolver.query(contentUri, null, null, null, null) ?: throw Exception("Query returned null")
|
|
||||||
return cursor.use { c ->
|
|
||||||
val nameIndex = c.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)
|
|
||||||
val sizeIndex = c.getColumnIndexOrThrow(OpenableColumns.SIZE)
|
|
||||||
if (!c.moveToFirst()) {
|
|
||||||
throw FileNotFoundException("Not found: $contentUri")
|
|
||||||
}
|
|
||||||
val size = c.getLong(sizeIndex)
|
|
||||||
if (size == 0L) {
|
|
||||||
// Content provider URIs (e.g. content://io.heckel.ntfy.provider/cache_files/DQ4o7DitZAmw) return an entry, even
|
|
||||||
// when they do not exist, but with an empty size. This is a practical/fast way to weed out non-existing files.
|
|
||||||
throw FileNotFoundException("Not found or empty: $contentUri")
|
|
||||||
}
|
|
||||||
FileInfo(
|
|
||||||
filename = c.getString(nameIndex),
|
|
||||||
size = c.getLong(sizeIndex)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun maybeFileStat(context: Context, contentUri: String?): FileInfo? {
|
|
||||||
return try {
|
|
||||||
fileStat(context, contentUri?.toUri()) // Throws if the file does not exist
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class FileInfo(
|
|
||||||
val filename: String,
|
|
||||||
val size: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Generates a (cryptographically secure) random string of a certain length
|
|
||||||
fun randomString(len: Int): String {
|
|
||||||
val random = SecureRandom()
|
|
||||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray()
|
|
||||||
return (1..len).map { chars[random.nextInt(chars.size)] }.joinToString("")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generates a random, positive subscription ID between 0-10M. This ensures that it doesn't have issues
|
|
||||||
// when exported to JSON. It uses SecureRandom, because Random causes issues in the emulator (generating the
|
|
||||||
// same value again and again), sometimes.
|
|
||||||
fun randomSubscriptionId(): Long {
|
|
||||||
return SecureRandom().nextLong().absoluteValue % 100_000_000
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allows letting multiple variables at once, see https://stackoverflow.com/a/35522422/1440785
|
|
||||||
inline fun <T1: Any, T2: Any, R: Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2)->R?): R? {
|
|
||||||
return if (p1 != null && p2 != null) block(p1, p2) else null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun formatBytes(bytes: Long, decimals: Int = 1): String {
|
|
||||||
val absB = if (bytes == Long.MIN_VALUE) Long.MAX_VALUE else abs(bytes)
|
|
||||||
if (absB < 1024) {
|
|
||||||
return "$bytes B"
|
|
||||||
}
|
|
||||||
var value = absB
|
|
||||||
val ci = StringCharacterIterator("KMGTPE")
|
|
||||||
var i = 40
|
|
||||||
while (i >= 0 && absB > 0xfffccccccccccccL shr i) {
|
|
||||||
value = value shr 10
|
|
||||||
ci.next()
|
|
||||||
i -= 10
|
|
||||||
}
|
|
||||||
value *= java.lang.Long.signum(bytes).toLong()
|
|
||||||
return java.lang.String.format("%.${decimals}f %cB", value / 1024.0, ci.current())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun mimeTypeToIconResource(mimeType: String?): Int {
|
|
||||||
return if (mimeType?.startsWith("image/") == true) {
|
|
||||||
R.drawable.ic_file_image_red_24dp
|
|
||||||
} else if (mimeType?.startsWith("video/") == true) {
|
|
||||||
R.drawable.ic_file_video_orange_24dp
|
|
||||||
} else if (mimeType?.startsWith("audio/") == true) {
|
|
||||||
R.drawable.ic_file_audio_purple_24dp
|
|
||||||
} else if (mimeType == ANDROID_APP_MIME_TYPE) {
|
|
||||||
R.drawable.ic_file_app_gray_24dp
|
|
||||||
} else {
|
|
||||||
R.drawable.ic_file_document_blue_24dp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if battery optimization is enabled, see https://stackoverflow.com/a/49098293/1440785
|
|
||||||
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
|
||||||
val powerManager = context.applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
||||||
val appName = context.applicationContext.packageName
|
|
||||||
return powerManager.isIgnoringBatteryOptimizations(appName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true if dark mode is on, see https://stackoverflow.com/a/60761189/1440785
|
|
||||||
fun Context.systemDarkThemeOn(): Boolean {
|
|
||||||
return resources.configuration.uiMode and
|
|
||||||
Configuration.UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isDarkThemeOn(context: Context): Boolean {
|
|
||||||
val darkMode = Repository.getInstance(context).getDarkMode()
|
|
||||||
if (darkMode == AppCompatDelegate.MODE_NIGHT_YES) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (darkMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM && context.systemDarkThemeOn()) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://cketti.de/2020/05/23/content-uris-and-okhttp/
|
|
||||||
class ContentUriRequestBody(
|
|
||||||
private val resolver: ContentResolver,
|
|
||||||
private val uri: Uri,
|
|
||||||
private val size: Long
|
|
||||||
) : RequestBody() {
|
|
||||||
override fun contentLength(): Long {
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
override fun contentType(): MediaType? {
|
|
||||||
val contentType = resolver.getType(uri)
|
|
||||||
return contentType?.toMediaTypeOrNull()
|
|
||||||
}
|
|
||||||
override fun writeTo(sink: BufferedSink) {
|
|
||||||
val inputStream = resolver.openInputStream(uri) ?: throw IOException("Couldn't open content URI for reading")
|
|
||||||
inputStream.source().use { source ->
|
|
||||||
sink.writeAll(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: make this work in Android 34+
|
|
||||||
// Hack: Make end icon for drop down smaller, see https://stackoverflow.com/a/57098715/1440785
|
|
||||||
fun View.makeEndIconSmaller(resources: Resources) {
|
|
||||||
// val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 30f, resources.displayMetrics)
|
|
||||||
// val endIconImageView = findViewById<ImageView>(R.id.text_input_end_icon)
|
|
||||||
// endIconImageView.minimumHeight = dimension.toInt()
|
|
||||||
// endIconImageView.minimumWidth = dimension.toInt()
|
|
||||||
// requestLayout()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shows the ripple effect on the view, if it is ripple-able, see https://stackoverflow.com/a/56314062/1440785
|
|
||||||
fun View.showRipple() {
|
|
||||||
if (background is RippleDrawable) {
|
|
||||||
background.state = intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hides the ripple effect on the view, if it is ripple-able, see https://stackoverflow.com/a/56314062/1440785
|
|
||||||
fun View.hideRipple() {
|
|
||||||
if (background is RippleDrawable) {
|
|
||||||
background.state = intArrayOf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggles the ripple effect on the view, if it is ripple-able
|
|
||||||
fun View.ripple(scope: CoroutineScope) {
|
|
||||||
showRipple()
|
|
||||||
scope.launch(Dispatchers.Main) {
|
|
||||||
delay(200)
|
|
||||||
hideRipple()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Uri.readBitmapFromUri(context: Context): Bitmap {
|
|
||||||
val resolver = context.applicationContext.contentResolver
|
|
||||||
val bitmapStream = resolver.openInputStream(this)
|
|
||||||
val bitmap = BitmapFactory.decodeStream(bitmapStream)
|
|
||||||
if (bitmap.byteCount > 100 * 1024 * 1024) {
|
|
||||||
// If the Bitmap is too large to be rendered (100 MB), it will throw a RuntimeException downstream.
|
|
||||||
// This workaround throws a catchable exception instead. See issue #474. From https://stackoverflow.com/a/53334563/1440785
|
|
||||||
throw Exception("Bitmap too large to draw on Canvas (${bitmap.byteCount} bytes)")
|
|
||||||
}
|
|
||||||
return bitmap
|
|
||||||
}
|
|
||||||
|
|
||||||
fun String.readBitmapFromUri(context: Context): Bitmap {
|
|
||||||
return this.toUri().readBitmapFromUri(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun String.readBitmapFromUriOrNull(context: Context): Bitmap? {
|
|
||||||
return try {
|
|
||||||
this.readBitmapFromUri(context)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TextWatcher that only implements the afterTextChanged method
|
|
||||||
class AfterChangedTextWatcher(val afterTextChangedFn: (s: Editable?) -> Unit) : TextWatcher {
|
|
||||||
override fun afterTextChanged(s: Editable?) {
|
|
||||||
afterTextChangedFn(s)
|
|
||||||
}
|
|
||||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
|
||||||
// Nothing
|
|
||||||
}
|
|
||||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
|
||||||
// Nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ensureSafeNewFile(dir: File, name: String): File {
|
|
||||||
val safeName = name.replace("[^-_.()\\w]+".toRegex(), "_");
|
|
||||||
val file = File(dir, safeName)
|
|
||||||
if (!file.exists()) {
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
(1..1000).forEach { i ->
|
|
||||||
val newFile = File(dir, if (file.extension == "") {
|
|
||||||
"${file.nameWithoutExtension} ($i)"
|
|
||||||
} else {
|
|
||||||
"${file.nameWithoutExtension} ($i).${file.extension}"
|
|
||||||
})
|
|
||||||
if (!newFile.exists()) {
|
|
||||||
return newFile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw Exception("Cannot find safe file")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun copyToClipboard(context: Context, label: String, message: String) {
|
|
||||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
val clip = ClipData.newPlainText(label, message)
|
|
||||||
clipboard.setPrimaryClip(clip)
|
|
||||||
|
|
||||||
// https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications
|
|
||||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
|
||||||
val copied = context.getString(R.string.common_copied_to_clipboard)
|
|
||||||
Toast.makeText(context, copied, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun String.sha256(): String {
|
|
||||||
val md = MessageDigest.getInstance("SHA-256")
|
|
||||||
val digest = md.digest(this.toByteArray())
|
|
||||||
return digest.fold("") { str, it -> str + "%02x".format(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Button.dangerButton() {
|
|
||||||
setTextAppearance(R.style.DangerText)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Long.nullIfZero(): Long? {
|
|
||||||
return if (this == 0L) return null else this
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:interpolator="@android:interpolator/decelerate_cubic">
|
|
||||||
<translate
|
|
||||||
android:duration="300"
|
|
||||||
android:fromYDelta="100%"
|
|
||||||
android:toYDelta="0" />
|
|
||||||
</set>
|
|
||||||
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:interpolator="@android:interpolator/accelerate_cubic">
|
|
||||||
<translate
|
|
||||||
android:duration="250"
|
|
||||||
android:fromYDelta="0"
|
|
||||||
android:toYDelta="100%" />
|
|
||||||
</set>
|
|
||||||
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24.0"
|
|
||||||
android:viewportHeight="24.0">
|
|
||||||
<path
|
|
||||||
android:fillColor="#000000"
|
|
||||||
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M20,2L4,2c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,16L5.17,16L4,17.17L4,4h16v12zM11,5h2v6h-2zM11,13h2v2h-2z"
|
|
||||||
android:fillColor="#FF9800"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M15.67,4L14,4L14,2h-4v2L8.33,4C7.6,4 7,4.6 7,5.33v15.33C7,21.4 7.6,22 8.33,22h7.33c0.74,0 1.34,-0.6 1.34,-1.33L17,5.33C17,4.6 16.4,4 15.67,4zM13,18h-2v-2h2v2zM13,14h-2L11,9h2v5z"
|
|
||||||
android:fillColor="#F44336"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M11,21h-1l1,-7H7.5c-0.88,0 -0.33,-0.75 -0.31,-0.78C8.48,10.94 10.42,7.54 13.01,3h1l-1,7h3.51c0.4,0 0.62,0.19 0.4,0.66C12.97,17.55 11,21 11,21z"
|
|
||||||
android:fillColor="#555555"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:tint="?colorControlNormal"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M12.5449,2.1992 L12.3145,2.6035C9.7352,7.1246 7.809,10.5009 6.5176,12.7832c0.0118,-0.0179 -0.0053,0.0121 -0.0254,0.043 -0.024,0.0369 -0.055,0.0842 -0.0879,0.1445 -0.0657,0.1206 -0.1489,0.2813 -0.1875,0.5273 -0.0386,0.2461 0.0075,0.6682 0.2988,0.9551 0.2913,0.2869 0.6507,0.3477 0.9844,0.3477h2.5781l-1,7h2.3867l0.2305,-0.4043c0,0 1.9682,-3.4483 5.918,-10.3379l0.0176,-0.0293L17.6445,11C17.8155,10.6348 17.9095,10.1065 17.5996,9.6816 17.2897,9.2568 16.8462,9.1992 16.5195,9.1992h-2.5859l0.998,-7zM12.959,4.707 L12.0879,10.8008h3.8301c-3.0779,5.37 -4.4616,7.7886 -4.8691,8.502l0.873,-6.1035L8.123,13.1992C9.238,11.2305 10.9448,8.2378 12.959,4.707Z"
|
|
||||||
android:fillColor="#FFFFFF"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:tint="?colorControlNormal"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M11,21h-1l1,-7H7.5c-0.88,0 -0.33,-0.75 -0.31,-0.78C8.48,10.94 10.42,7.54 13.01,3h1l-1,7h3.51c0.4,0 0.62,0.19 0.4,0.66C12.97,17.55 11,21 11,21z"
|
|
||||||
android:fillColor="@android:color/white"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2019 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportHeight="24.0"
|
|
||||||
android:viewportWidth="24.0"
|
|
||||||
android:width="24dp"
|
|
||||||
tools:ignore="NewApi">
|
|
||||||
<path
|
|
||||||
android:fillColor="#888888"
|
|
||||||
android:pathData="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="oval" >
|
|
||||||
<solid android:color="#338574" />
|
|
||||||
</shape>
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24.0"
|
|
||||||
android:viewportHeight="24.0">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
|
|
||||||
</vector>
|
|
||||||
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z"
|
|
||||||
android:fillColor="@android:color/white"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
|
|
||||||
android:fillColor="#FFFFFF"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="20dp"
|
|
||||||
android:height="20dp"
|
|
||||||
android:viewportWidth="20"
|
|
||||||
android:viewportHeight="20"
|
|
||||||
android:tint="?attr/colorControlNormal">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M4,6v10c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2L16,6L4,6zM17,3h-3l-1,-1L7,2L6,3L3,3v2h14L17,3z"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2019 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportHeight="24.0"
|
|
||||||
android:viewportWidth="24.0"
|
|
||||||
android:width="24dp"
|
|
||||||
tools:ignore="NewApi">
|
|
||||||
<path
|
|
||||||
android:fillColor="#888888"
|
|
||||||
android:pathData="M7 10l5 5 5-5z"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ Copyright (C) 2019 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportHeight="24.0"
|
|
||||||
android:viewportWidth="24.0"
|
|
||||||
android:width="24dp"
|
|
||||||
tools:ignore="NewApi">
|
|
||||||
<path
|
|
||||||
android:fillColor="#888888"
|
|
||||||
android:pathData="M7 14l5-5 5 5z"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z"
|
|
||||||
android:fillColor="#F44336"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M12,8l-6,6 1.41,1.41L12,10.83l4.59,4.58L18,14z"
|
|
||||||
android:fillColor="#808080"/>
|
|
||||||
</vector>
|
|
||||||
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M17.6,9.48l1.84,-3.18c0.16,-0.31 0.04,-0.69 -0.26,-0.85c-0.29,-0.15 -0.65,-0.06 -0.83,0.22l-1.88,3.24c-2.86,-1.21 -6.08,-1.21 -8.94,0L5.65,5.67c-0.19,-0.29 -0.58,-0.38 -0.87,-0.2C4.5,5.65 4.41,6.01 4.56,6.3L6.4,9.48C3.3,11.25 1.28,14.44 1,18h22C22.72,14.44 20.7,11.25 17.6,9.48zM7,15.25c-0.69,0 -1.25,-0.56 -1.25,-1.25c0,-0.69 0.56,-1.25 1.25,-1.25S8.25,13.31 8.25,14C8.25,14.69 7.69,15.25 7,15.25zM17,15.25c-0.69,0 -1.25,-0.56 -1.25,-1.25c0,-0.69 0.56,-1.25 1.25,-1.25s1.25,0.56 1.25,1.25C18.25,14.69 17.69,15.25 17,15.25z"
|
|
||||||
android:fillColor="#555555"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M12,3l0.01,10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55C7.79,13 6,14.79 6,17s1.79,4 4.01,4S14,19.21 14,17L14,7h4L18,3h-6zM10.01,19c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"
|
|
||||||
android:fillColor="#B300FF"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M8,16h8v2L8,18zM8,12h8v2L8,14zM14,2L6,2c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6zM18,20L6,20L6,4h7v5h5v11z"
|
|
||||||
android:fillColor="#00ADFF"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M19,5v14L5,19L5,5h14m0,-2L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM14.14,11.86l-3,3.87L9,13.14 6,17h12l-3.86,-5.14z"
|
|
||||||
android:fillColor="#E30000"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M4,6.47L5.76,10H20v8H4V6.47M22,4h-4l2,4h-3l-2,-4h-2l2,4h-3l-2,-4H8l2,4H7L5,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V4z"
|
|
||||||
android:fillColor="#FF9800"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="50dp"
|
|
||||||
android:height="50dp"
|
|
||||||
android:viewportWidth="50"
|
|
||||||
android:viewportHeight="50">
|
|
||||||
<path
|
|
||||||
android:pathData="m7.8399,6.35c-3.58,0 -6.6469,2.817 -6.6469,6.3983v0.003l0.0351,27.8668 -0.8991,6.6347 12.2261,-3.248L42.9487,44.0049c3.58,0 6.6469,-2.8208 6.6469,-6.4022v-24.8545c0,-3.5803 -3.0652,-6.3967 -6.6438,-6.3983h-0.0031zM7.8399,10.8662h35.1088,0.0031c1.2579,0.0013 2.1277,0.9164 2.1277,1.8821v24.8544c0,0.9666 -0.8714,1.8821 -2.1307,1.8821L11.8924,39.4849l-6.2114,1.8768 0.0633,-0.366 -0.0343,-28.2473c0,-0.9665 0.8706,-1.8821 2.13,-1.8821z"
|
|
||||||
android:strokeWidth="0.754022"
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:strokeColor="#00000000"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m11.5278,32.0849l0,-3.346l7.0363,-3.721q0.3397,-0.1732 0.6551,-0.2596 0.3397,-0.1153 0.6066,-0.1732 0.2912,-0.0288 0.5823,-0.0576l0,-0.2308q-0.2912,-0.0288 -0.5823,-0.1153 -0.2669,-0.0576 -0.6066,-0.1443 -0.3154,-0.1153 -0.6551,-0.2884l-7.0363,-3.721l0,-3.3749l10.8699,5.9132l0,3.6056z"
|
|
||||||
android:strokeWidth="0.525121"
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:strokeColor="#00000000"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m10.9661,15.6112l0,4.8516l7.3742,3.9002c0.0157,0.0077 0.0305,0.0128 0.0461,0.0204 -0.0157,0.0077 -0.0305,0.0128 -0.0461,0.0204l-7.3742,3.9002l0,4.8267l0.7961,-0.4333 11.1995,-6.0969l0,-4.463zM12.0931,17.6933 L21.8346,22.9981l0,2.7446l-9.7414,5.2999l0,-1.8679l6.6912,-3.5416 0.0084,-0.0051c0.1961,-0.0992 0.3826,-0.1724 0.5531,-0.2191l0.0127,0l0.0167,-0.0051c0.2034,-0.0691 0.3777,-0.1209 0.5279,-0.1545l1.0684,-0.1046l0,-1.4644l-0.5154,-0.0497c-0.1632,-0.0153 -0.3288,-0.0505 -0.4944,-0.0997l-0.0167,-0.0051 -0.0167,-0.0051c-0.1632,-0.0352 -0.3552,-0.0811 -0.5656,-0.1344 -0.1802,-0.0668 -0.3706,-0.1479 -0.5698,-0.2492l-0.0084,-0.0051 -6.6912,-3.5416z"
|
|
||||||
android:strokeWidth="0.525121"
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:strokeColor="#00000000"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m26.7503,30.9206l11.6118,0l0,3.1388L26.7503,34.0594Z"
|
|
||||||
android:strokeWidth="0.525121"
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:strokeColor="#00000000"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m26.1875,30.2775l0,0.6427 0,3.7845l12.7371,0l0,-4.4272zM27.3113,31.563l10.4896,0l0,1.8515l-10.4896,0z"
|
|
||||||
android:strokeWidth="0.525121"
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:strokeColor="#00000000"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M6,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"
|
|
||||||
android:fillColor="#555555"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="50dp"
|
|
||||||
android:height="50dp"
|
|
||||||
android:viewportWidth="50"
|
|
||||||
android:viewportHeight="50">
|
|
||||||
<path
|
|
||||||
android:pathData="m7.8399,6.35c-3.58,0 -6.6469,2.817 -6.6469,6.3983v0.003l0.0351,27.8668 -0.8991,6.6347 12.2261,-3.248L42.9487,44.0049c3.58,0 6.6469,-2.8208 6.6469,-6.4022v-24.8545c0,-3.5803 -3.0652,-6.3967 -6.6438,-6.3983h-0.0031zM7.8399,10.8662h35.1088,0.0031c1.2579,0.0013 2.1277,0.9164 2.1277,1.8821v24.8544c0,0.9666 -0.8714,1.8821 -2.1307,1.8821L11.8924,39.4849l-6.2114,1.8768 0.0633,-0.366 -0.0343,-28.2473c0,-0.9665 0.8706,-1.8821 2.13,-1.8821z"
|
|
||||||
android:strokeWidth="0.754022"
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:strokeColor="#00000000"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m11.5278,32.0849l0,-3.346l7.0363,-3.721q0.3397,-0.1732 0.6551,-0.2596 0.3397,-0.1153 0.6066,-0.1732 0.2912,-0.0288 0.5823,-0.0576l0,-0.2308q-0.2912,-0.0288 -0.5823,-0.1153 -0.2669,-0.0576 -0.6066,-0.1443 -0.3154,-0.1153 -0.6551,-0.2884l-7.0363,-3.721l0,-3.3749l10.8699,5.9132l0,3.6056z"
|
|
||||||
android:strokeWidth="0.525121"
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:strokeColor="#00000000"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m10.9661,15.6112l0,4.8516l7.3742,3.9002c0.0157,0.0077 0.0305,0.0128 0.0461,0.0204 -0.0157,0.0077 -0.0305,0.0128 -0.0461,0.0204l-7.3742,3.9002l0,4.8267l0.7961,-0.4333 11.1995,-6.0969l0,-4.463zM12.0931,17.6933 L21.8346,22.9981l0,2.7446l-9.7414,5.2999l0,-1.8679l6.6912,-3.5416 0.0084,-0.0051c0.1961,-0.0992 0.3826,-0.1724 0.5531,-0.2191l0.0127,0l0.0167,-0.0051c0.2034,-0.0691 0.3777,-0.1209 0.5279,-0.1545l1.0684,-0.1046l0,-1.4644l-0.5154,-0.0497c-0.1632,-0.0153 -0.3288,-0.0505 -0.4944,-0.0997l-0.0167,-0.0051 -0.0167,-0.0051c-0.1632,-0.0352 -0.3552,-0.0811 -0.5656,-0.1344 -0.1802,-0.0668 -0.3706,-0.1479 -0.5698,-0.2492l-0.0084,-0.0051 -6.6912,-3.5416z"
|
|
||||||
android:strokeWidth="0.525121"
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:strokeColor="#00000000"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m26.7503,30.9206l11.6118,0l0,3.1388L26.7503,34.0594Z"
|
|
||||||
android:strokeWidth="0.525121"
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:strokeColor="#00000000"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m26.1875,30.2775l0,0.6427 0,3.7845l12.7371,0l0,-4.4272zM27.3113,31.563l10.4896,0l0,1.8515l-10.4896,0z"
|
|
||||||
android:strokeWidth="0.525121"
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:strokeColor="#00000000"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M12,2.5C11.17,2.5 10.5,3.17 10.5,4L10.5,4.6797C9.556,4.9041 8.749,5.3367 8.0859,5.9219L9.5273,7.3672C10.1748,6.8243 11.0077,6.5 12,6.5C14.49,6.5 16,8.52 16,11L16,13.8555L18,15.8613L18,11C18,7.93 16.37,5.3597 13.5,4.6797L13.5,4C13.5,3.17 12.83,2.5 12,2.5zM6.7715,7.6289C6.2688,8.6106 6,9.762 6,11L6,16L4,18L4,19L18.1172,19L16,16.8789L16,17L8,17L8,11C8,10.3476 8.1073,9.7283 8.3066,9.168L6.7715,7.6289zM10,20C10,21.1 10.9,22 12,22C13.1,22 14,21.1 14,20L10,20z"
|
|
||||||
android:fillColor="#555555"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M3.543,3.3965 L2.0313,4.9043 19.5234,22.4395 21.0352,20.9316Z"
|
|
||||||
android:fillColor="#555555"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M12,2.5C11.17,2.5 10.5,3.17 10.5,4L10.5,4.6797C9.556,4.9041 8.749,5.3367 8.0859,5.9219L9.5273,7.3672C10.1748,6.8243 11.0077,6.5 12,6.5C14.1766,6.5 15.6028,8.0429 15.9277,10.0879A6.6092,6.6092 0,0 1,16.502 10.0371A6.6092,6.6092 0,0 1,17.9609 10.2031C17.7024,7.4927 16.1179,5.2999 13.5,4.6797L13.5,4C13.5,3.17 12.83,2.5 12,2.5zM3.543,3.3965L2.0313,4.9043L6.2266,9.1094C6.0793,9.7072 6,10.3404 6,11L6,16L4,18L4,19L10.334,19A6.6092,6.6092 0,0 1,9.9238 17L8,17L8,11C8,10.9637 8.0032,10.9287 8.0039,10.8926L10.6738,13.5684A6.6092,6.6092 0,0 1,11.9824 11.8555L3.543,3.3965z"
|
|
||||||
android:fillColor="#555555"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m16.8553,10.7743c-3.3109,0 -6.002,2.6955 -6.002,6.0059 0,3.3104 2.6911,6.0078 6.002,6.0078 3.316,0 6.0117,-2.6969 6.0117,-6.0078 0,-3.3109 -2.6957,-6.0059 -6.0117,-6.0059zM16.8592,12.7861c2.2124,0 3.9941,1.7818 3.9941,3.9941 0,2.2124 -1.7818,3.9941 -3.9941,3.9941 -2.2124,0 -3.9941,-1.7818 -3.9941,-3.9941 0,-2.2124 1.7818,-3.9941 3.9941,-3.9941z"
|
|
||||||
android:fillColor="#555555"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m15.6308,13.426v4.041l3.5195,2.1113 0.8887,-1.4551 -2.6719,-1.5859v-3.1113h-0.4512z"
|
|
||||||
android:fillColor="#555555"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:tint="?attr/colorControlNormal"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M12,2.5C11.17,2.5 10.5,3.17 10.5,4L10.5,4.6797C9.556,4.9041 8.749,5.3367 8.0859,5.9219L9.5273,7.3672C10.1748,6.8243 11.0077,6.5 12,6.5C14.1766,6.5 15.6028,8.0429 15.9277,10.0879A6.6092,6.6092 0,0 1,16.502 10.0371A6.6092,6.6092 0,0 1,17.9609 10.2031C17.7024,7.4927 16.1179,5.2999 13.5,4.6797L13.5,4C13.5,3.17 12.83,2.5 12,2.5zM3.543,3.3965L2.0313,4.9043L6.2266,9.1094C6.0793,9.7072 6,10.3404 6,11L6,16L4,18L4,19L10.334,19A6.6092,6.6092 0,0 1,9.9238 17L8,17L8,11C8,10.9637 8.0032,10.9287 8.0039,10.8926L10.6738,13.5684A6.6092,6.6092 0,0 1,11.9824 11.8555L3.543,3.3965z"
|
|
||||||
android:fillColor="#FFFFFF"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m16.8553,10.7743c-3.3109,0 -6.002,2.6955 -6.002,6.0059 0,3.3104 2.6911,6.0078 6.002,6.0078 3.316,0 6.0117,-2.6969 6.0117,-6.0078 0,-3.3109 -2.6957,-6.0059 -6.0117,-6.0059zM16.8592,12.7861c2.2124,0 3.9941,1.7818 3.9941,3.9941 0,2.2124 -1.7818,3.9941 -3.9941,3.9941 -2.2124,0 -3.9941,-1.7818 -3.9941,-3.9941 0,-2.2124 1.7818,-3.9941 3.9941,-3.9941z"
|
|
||||||
android:fillColor="#FFFFFF"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m15.6308,13.426v4.041l3.5195,2.1113 0.8887,-1.4551 -2.6719,-1.5859v-3.1113h-0.4512z"
|
|
||||||
android:fillColor="#FFFFFF"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:tint="?attr/colorControlNormal"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M12,2.5C11.17,2.5 10.5,3.17 10.5,4L10.5,4.6797C9.556,4.9041 8.749,5.3367 8.0859,5.9219L9.5273,7.3672C10.1748,6.8243 11.0077,6.5 12,6.5C14.49,6.5 16,8.52 16,11L16,13.8555L18,15.8613L18,11C18,7.93 16.37,5.3597 13.5,4.6797L13.5,4C13.5,3.17 12.83,2.5 12,2.5zM6.7715,7.6289C6.2688,8.6106 6,9.762 6,11L6,16L4,18L4,19L18.1172,19L16,16.8789L16,17L8,17L8,11C8,10.3476 8.1073,9.7283 8.3066,9.168L6.7715,7.6289zM10,20C10,21.1 10.9,22 12,22C13.1,22 14,21.1 14,20L10,20z"
|
|
||||||
android:fillColor="#FFFFFF"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M3.543,3.3965 L2.0313,4.9043 19.5234,22.4395 21.0352,20.9316Z"
|
|
||||||
android:fillColor="#FFFFFF"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:tint="?attr/colorControlNormal"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"
|
|
||||||
android:fillColor="#FFFFFF"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="m12.195,20.8283a1.2747,1.2747 0,0 0,0.6616 -0.1852l6.6466,-4.0372a1.2746,1.2746 0,0 0,0.4275 -1.7511,1.2746 1.2746,0 0,0 -1.7509,-0.4277l-5.9848,3.6353 -5.9848,-3.6353a1.2746,1.2746 0,0 0,-1.7509 0.4277,1.2746 1.2746,0 0,0 0.4275,1.7511l6.6464,4.0372a1.2747,1.2747 0,0 0,0.6618 0.1852z"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="0.0919748"
|
|
||||||
android:fillColor="#999999"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m12.195,15.694a1.2747,1.2747 0,0 0,0.6616 -0.1852l6.6466,-4.0372A1.2746,1.2746 0,0 0,19.9307 9.7205,1.2746 1.2746,0 0,0 18.1798,9.2928L12.195,12.9281 6.2102,9.2928a1.2746,1.2746 0,0 0,-1.7509 0.4277,1.2746 1.2746,0 0,0 0.4275,1.7511l6.6464,4.0372a1.2747,1.2747 0,0 0,0.6618 0.1852z"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="0.0919748"
|
|
||||||
android:fillColor="#b3b3b3"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m12.1168,10.4268a1.2747,1.2747 0,0 0,0.6616 -0.1852l6.6466,-4.0372a1.2746,1.2746 0,0 0,0.4275 -1.7511,1.2746 1.2746,0 0,0 -1.7509,-0.4277l-5.9848,3.6353 -5.9848,-3.6353a1.2746,1.2746 0,0 0,-1.7509 0.4277,1.2746 1.2746,0 0,0 0.4275,1.7511L11.455,10.2416a1.2747,1.2747 0,0 0,0.6618 0.1852z"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="0.0919748"
|
|
||||||
android:fillColor="#cccccc"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="m12.1727,17.7744a1.2747,1.2747 0,0 0,0.6616 -0.1852l6.6466,-4.0372a1.2746,1.2746 0,0 0,0.4275 -1.7511,1.2746 1.2746,0 0,0 -1.7509,-0.4277L12.1727,15.0085 6.1879,11.3731a1.2746,1.2746 0,0 0,-1.7509 0.4277,1.2746 1.2746,0 0,0 0.4275,1.7511l6.6464,4.0372a1.2747,1.2747 0,0 0,0.6618 0.1852z"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="0.0919748"
|
|
||||||
android:fillColor="#999999"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m12.1727,12.64a1.2747,1.2747 0,0 0,0.6616 -0.1852L19.4809,8.4177A1.2746,1.2746 0,0 0,19.9084 6.6666,1.2746 1.2746,0 0,0 18.1575,6.2388L12.1727,9.8742 6.1879,6.2388a1.2746,1.2746 0,0 0,-1.7509 0.4277,1.2746 1.2746,0 0,0 0.4275,1.7511l6.6464,4.0372a1.2747,1.2747 0,0 0,0.6618 0.1852z"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="0.0919748"
|
|
||||||
android:fillColor="#b3b3b3"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M4,10h16v1.5H4z"
|
|
||||||
android:fillColor="#2196F3"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M4,13h16v1.5H4z"
|
|
||||||
android:fillColor="#2196F3"/>
|
|
||||||
</vector>
|
|
||||||
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M12.1168,6.5394A1.2747,1.2747 0,0 0,11.4552 6.7246l-6.6466,4.0372a1.2746,1.2746 0,0 0,-0.4275 1.7511,1.2746 1.2746,0 0,0 1.7509,0.4277l5.9848,-3.6353 5.9848,3.6353A1.2746,1.2746 0,0 0,19.8525 12.5129,1.2746 1.2746,0 0,0 19.425,10.7618L12.7786,6.7246A1.2747,1.2747 0,0 0,12.1168 6.5394Z"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="0.0919748"
|
|
||||||
android:fillColor="#c60000"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m12.195,11.8067a1.2747,1.2747 0,0 0,-0.6616 0.1852l-6.6466,4.0372a1.2746,1.2746 0,0 0,-0.4275 1.7511,1.2746 1.2746,0 0,0 1.7509,0.4277l5.9848,-3.6353 5.9848,3.6353a1.2746,1.2746 0,0 0,1.7509 -0.4277,1.2746 1.2746,0 0,0 -0.4275,-1.7511l-6.6464,-4.0372a1.2747,1.2747 0,0 0,-0.6618 -0.1852z"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="0.0919748"
|
|
||||||
android:fillColor="#de0000"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M12.1168,3.4051A1.2747,1.2747 0,0 0,11.4552 3.5903L4.8086,7.6275A1.2746,1.2746 0,0 0,4.381 9.3786,1.2746 1.2746,0 0,0 6.132,9.8063L12.1168,6.171 18.1016,9.8063A1.2746,1.2746 0,0 0,19.8525 9.3786,1.2746 1.2746,0 0,0 19.425,7.6275L12.7786,3.5903A1.2747,1.2747 0,0 0,12.1168 3.4051Z"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="0.0919748"
|
|
||||||
android:fillColor="#aa0000"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M12.1168,8.5394A1.2747,1.2747 0,0 0,11.4552 8.7246l-6.6466,4.0372a1.2746,1.2746 0,0 0,-0.4275 1.7511,1.2746 1.2746,0 0,0 1.7509,0.4277l5.9848,-3.6353 5.9848,3.6353A1.2746,1.2746 0,0 0,19.8525 14.5129,1.2746 1.2746,0 0,0 19.425,12.7618L12.7786,8.7246A1.2747,1.2747 0,0 0,12.1168 8.5394Z"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="0.0919748"
|
|
||||||
android:fillColor="#c60000"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
<path
|
|
||||||
android:pathData="m12.195,13.8067a1.2747,1.2747 0,0 0,-0.6616 0.1852l-6.6466,4.0372a1.2746,1.2746 0,0 0,-0.4275 1.7511,1.2746 1.2746,0 0,0 1.7509,0.4277l5.9848,-3.6353 5.9848,3.6353a1.2746,1.2746 0,0 0,1.7509 -0.4277,1.2746 1.2746,0 0,0 -0.4275,-1.7511l-6.6464,-4.0372a1.2747,1.2747 0,0 0,-0.6618 -0.1852z"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:strokeWidth="0.0919748"
|
|
||||||
android:fillColor="#de0000"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeLineCap="round"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M4.01,6.03l7.51,3.22 -7.52,-1 0.01,-2.22m7.5,8.72L4,17.97v-2.22l7.51,-1M2.01,3L2,10l15,2 -15,2 0.01,7L23,12 2.01,3z"
|
|
||||||
android:fillColor="#FFFFFF"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<vector android:height="24dp" android:viewportHeight="50"
|
|
||||||
android:viewportWidth="50" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<path android:fillColor="#888888"
|
|
||||||
android:pathData="m7.8399,6.35c-3.58,0 -6.6469,2.817 -6.6469,6.3983v0.003l0.0351,27.8668 -0.8991,6.6347 12.2261,-3.248L42.9487,44.0049c3.58,0 6.6469,-2.8208 6.6469,-6.4022v-24.8545c0,-3.5803 -3.0652,-6.3967 -6.6438,-6.3983h-0.0031zM7.8399,10.8662h35.1088,0.0031c1.2579,0.0013 2.1277,0.9164 2.1277,1.8821v24.8544c0,0.9666 -0.8714,1.8821 -2.1307,1.8821L11.8924,39.4849l-6.2114,1.8768 0.0633,-0.366 -0.0343,-28.2473c0,-0.9665 0.8706,-1.8821 2.13,-1.8821z"
|
|
||||||
android:strokeColor="#00000000" android:strokeWidth="0.754022"/>
|
|
||||||
<path android:fillColor="#888888"
|
|
||||||
android:pathData="m11.5278,32.0849l0,-3.346l7.0363,-3.721q0.3397,-0.1732 0.6551,-0.2596 0.3397,-0.1153 0.6066,-0.1732 0.2912,-0.0288 0.5823,-0.0576l0,-0.2308q-0.2912,-0.0288 -0.5823,-0.1153 -0.2669,-0.0576 -0.6066,-0.1443 -0.3154,-0.1153 -0.6551,-0.2884l-7.0363,-3.721l0,-3.3749l10.8699,5.9132l0,3.6056z"
|
|
||||||
android:strokeColor="#00000000" android:strokeWidth="0.525121"/>
|
|
||||||
<path android:fillColor="#888888"
|
|
||||||
android:pathData="m10.9661,15.6112l0,4.8516l7.3742,3.9002c0.0157,0.0077 0.0305,0.0128 0.0461,0.0204 -0.0157,0.0077 -0.0305,0.0128 -0.0461,0.0204l-7.3742,3.9002l0,4.8267l0.7961,-0.4333 11.1995,-6.0969l0,-4.463zM12.0931,17.6933 L21.8346,22.9981l0,2.7446l-9.7414,5.2999l0,-1.8679l6.6912,-3.5416 0.0084,-0.0051c0.1961,-0.0992 0.3826,-0.1724 0.5531,-0.2191l0.0127,0l0.0167,-0.0051c0.2034,-0.0691 0.3777,-0.1209 0.5279,-0.1545l1.0684,-0.1046l0,-1.4644l-0.5154,-0.0497c-0.1632,-0.0153 -0.3288,-0.0505 -0.4944,-0.0997l-0.0167,-0.0051 -0.0167,-0.0051c-0.1632,-0.0352 -0.3552,-0.0811 -0.5656,-0.1344 -0.1802,-0.0668 -0.3706,-0.1479 -0.5698,-0.2492l-0.0084,-0.0051 -6.6912,-3.5416z"
|
|
||||||
android:strokeColor="#00000000" android:strokeWidth="0.525121"/>
|
|
||||||
<path android:fillColor="#888888"
|
|
||||||
android:pathData="m26.7503,30.9206l11.6118,0l0,3.1388L26.7503,34.0594Z"
|
|
||||||
android:strokeColor="#00000000" android:strokeWidth="0.525121"/>
|
|
||||||
<path android:fillColor="#888888"
|
|
||||||
android:pathData="m26.1875,30.2775l0,0.6427 0,3.7845l12.7371,0l0,-4.4272zM27.3113,31.563l10.4896,0l0,1.8515l-10.4896,0z"
|
|
||||||
android:strokeColor="#00000000" android:strokeWidth="0.525121"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<vector android:height="48dp" android:viewportHeight="50"
|
|
||||||
android:viewportWidth="50" android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<path android:fillColor="#888888"
|
|
||||||
android:pathData="m7.8399,6.35c-3.58,0 -6.6469,2.817 -6.6469,6.3983v0.003l0.0351,27.8668 -0.8991,6.6347 12.2261,-3.248L42.9487,44.0049c3.58,0 6.6469,-2.8208 6.6469,-6.4022v-24.8545c0,-3.5803 -3.0652,-6.3967 -6.6438,-6.3983h-0.0031zM7.8399,10.8662h35.1088,0.0031c1.2579,0.0013 2.1277,0.9164 2.1277,1.8821v24.8544c0,0.9666 -0.8714,1.8821 -2.1307,1.8821L11.8924,39.4849l-6.2114,1.8768 0.0633,-0.366 -0.0343,-28.2473c0,-0.9665 0.8706,-1.8821 2.13,-1.8821z"
|
|
||||||
android:strokeColor="#00000000" android:strokeWidth="0.754022"/>
|
|
||||||
<path android:fillColor="#888888"
|
|
||||||
android:pathData="m11.5278,32.0849l0,-3.346l7.0363,-3.721q0.3397,-0.1732 0.6551,-0.2596 0.3397,-0.1153 0.6066,-0.1732 0.2912,-0.0288 0.5823,-0.0576l0,-0.2308q-0.2912,-0.0288 -0.5823,-0.1153 -0.2669,-0.0576 -0.6066,-0.1443 -0.3154,-0.1153 -0.6551,-0.2884l-7.0363,-3.721l0,-3.3749l10.8699,5.9132l0,3.6056z"
|
|
||||||
android:strokeColor="#00000000" android:strokeWidth="0.525121"/>
|
|
||||||
<path android:fillColor="#888888"
|
|
||||||
android:pathData="m10.9661,15.6112l0,4.8516l7.3742,3.9002c0.0157,0.0077 0.0305,0.0128 0.0461,0.0204 -0.0157,0.0077 -0.0305,0.0128 -0.0461,0.0204l-7.3742,3.9002l0,4.8267l0.7961,-0.4333 11.1995,-6.0969l0,-4.463zM12.0931,17.6933 L21.8346,22.9981l0,2.7446l-9.7414,5.2999l0,-1.8679l6.6912,-3.5416 0.0084,-0.0051c0.1961,-0.0992 0.3826,-0.1724 0.5531,-0.2191l0.0127,0l0.0167,-0.0051c0.2034,-0.0691 0.3777,-0.1209 0.5279,-0.1545l1.0684,-0.1046l0,-1.4644l-0.5154,-0.0497c-0.1632,-0.0153 -0.3288,-0.0505 -0.4944,-0.0997l-0.0167,-0.0051 -0.0167,-0.0051c-0.1632,-0.0352 -0.3552,-0.0811 -0.5656,-0.1344 -0.1802,-0.0668 -0.3706,-0.1479 -0.5698,-0.2492l-0.0084,-0.0051 -6.6912,-3.5416z"
|
|
||||||
android:strokeColor="#00000000" android:strokeWidth="0.525121"/>
|
|
||||||
<path android:fillColor="#888888"
|
|
||||||
android:pathData="m26.7503,30.9206l11.6118,0l0,3.1388L26.7503,34.0594Z"
|
|
||||||
android:strokeColor="#00000000" android:strokeWidth="0.525121"/>
|
|
||||||
<path android:fillColor="#888888"
|
|
||||||
android:pathData="m26.1875,30.2775l0,0.6427 0,3.7845l12.7371,0l0,-4.4272zM27.3113,31.563l10.4896,0l0,1.8515l-10.4896,0z"
|
|
||||||
android:strokeColor="#00000000" android:strokeWidth="0.525121"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF8C00"
|
|
||||||
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
|
|
||||||
</vector>
|
|
||||||
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
|
|
||||||
</vector>
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:fitsSystemWindows="true"
|
|
||||||
tools:context=".ui.DetailActivity"
|
|
||||||
>
|
|
||||||
<include
|
|
||||||
android:id="@+id/app_bar_drawer"
|
|
||||||
layout="@layout/app_bar_drawer"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content" />
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:id="@+id/detail_content_layout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:background="@color/detail_activity_background"
|
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
style="@style/CardViewBackground"
|
|
||||||
android:id="@+id/detail_notification_list_container"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/detail_message_bar"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
android:clickable="true"
|
|
||||||
android:focusable="true"
|
|
||||||
android:paddingTop="5dp"
|
|
||||||
android:paddingBottom="5dp"
|
|
||||||
android:clipToPadding="false"
|
|
||||||
android:background="?android:attr/selectableItemBackground"
|
|
||||||
app:layoutManager="LinearLayoutManager"/>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/detail_no_notifications"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/detail_message_bar"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent">
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content" app:srcCompat="@drawable/ic_sms_gray_48dp"
|
|
||||||
android:id="@+id/detail_no_notifications_image"/>
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/detail_no_notifications_text"
|
|
||||||
android:text="@string/detail_no_notifications_text"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
|
|
||||||
android:padding="10dp" android:gravity="center_horizontal"
|
|
||||||
android:paddingStart="50dp" android:paddingEnd="50dp"/>
|
|
||||||
<TextView
|
|
||||||
android:text="@string/detail_how_to_intro"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/detail_how_to_intro"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginStart="50dp"
|
|
||||||
android:layout_marginEnd="50dp"/>
|
|
||||||
<TextView
|
|
||||||
android:text="@string/detail_how_to_example"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/detail_how_to_example"
|
|
||||||
android:layout_marginTop="7dp"
|
|
||||||
android:layout_marginStart="50dp"
|
|
||||||
android:layout_marginEnd="50dp"/>
|
|
||||||
<TextView
|
|
||||||
android:text="@string/detail_how_to_link"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/detail_how_to_link"
|
|
||||||
android:layout_marginTop="7dp"
|
|
||||||
android:layout_marginStart="50dp"
|
|
||||||
android:layout_marginEnd="50dp"
|
|
||||||
android:linksClickable="true"
|
|
||||||
android:autoLink="web"/>
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<include
|
|
||||||
android:id="@+id/detail_message_bar"
|
|
||||||
layout="@layout/view_message_bar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"/>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
|
||||||
android:id="@+id/detail_fab"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_margin="24dp"
|
|
||||||
android:contentDescription="@string/detail_fab_publish_description"
|
|
||||||
android:src="@drawable/ic_create_white_24dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_anchor="@id/detail_content_layout"
|
|
||||||
app:layout_anchorGravity="bottom|end"
|
|
||||||
style="@style/FloatingActionButton"/>
|
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
||||||
|
|
@ -1,304 +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"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:fitsSystemWindows="true">
|
|
||||||
|
|
||||||
<include
|
|
||||||
android:id="@+id/app_bar_drawer"
|
|
||||||
layout="@layout/app_bar_drawer"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content" />
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_marginBottom="16dp"
|
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
|
||||||
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
style="@style/BannerCardStyle"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
android:id="@+id/main_banner_battery"
|
|
||||||
android:visibility="visible"
|
|
||||||
>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/main_banner_battery_constraint" android:elevation="5dp">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="28dp"
|
|
||||||
android:layout_height="28dp" app:srcCompat="@drawable/ic_battery_alert_red_24dp"
|
|
||||||
android:id="@+id/main_banner_battery_image"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/main_banner_battery_text"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/main_banner_battery_text"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/main_banner_battery_text"
|
|
||||||
android:layout_marginStart="15dp"/>
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/main_banner_battery_text"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/main_banner_battery_text"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
android:layout_marginEnd="15dp" android:layout_marginTop="15dp"
|
|
||||||
app:layout_constraintStart_toEndOf="@+id/main_banner_battery_image"
|
|
||||||
android:layout_marginStart="10dp"/>
|
|
||||||
|
|
||||||
<androidx.constraintlayout.helper.widget.Flow
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:constraint_referenced_ids="main_banner_battery_ask_later,main_banner_battery_dontaskagain,main_banner_battery_fix_now"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/main_banner_battery_text"
|
|
||||||
app:flow_horizontalAlign="end"
|
|
||||||
app:flow_wrapMode="chain"
|
|
||||||
app:flow_horizontalStyle="packed"
|
|
||||||
android:layout_marginEnd="15dp"
|
|
||||||
android:id="@+id/main_banner_battery_flow"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
android:layout_marginStart="15dp"
|
|
||||||
app:flow_horizontalBias="1"
|
|
||||||
app:flow_verticalGap="0dp" app:flow_horizontalGap="0dp"/>
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/main_banner_battery_ask_later"
|
|
||||||
style="@style/Widget.MaterialComponents.Button.TextButton"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/main_banner_battery_button_remind_later"
|
|
||||||
tools:layout_editor_absoluteX="15dp" tools:layout_editor_absoluteY="67dp"/>
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/main_banner_battery_dontaskagain"
|
|
||||||
style="@style/Widget.MaterialComponents.Button.TextButton"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/main_banner_battery_button_dismiss"
|
|
||||||
tools:layout_editor_absoluteX="142dp" tools:layout_editor_absoluteY="71dp"/>
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/main_banner_battery_fix_now"
|
|
||||||
style="@style/Widget.MaterialComponents.Button.TextButton"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/main_banner_battery_button_fix_now"
|
|
||||||
tools:layout_editor_absoluteX="269dp" tools:layout_editor_absoluteY="67dp"/>
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
style="@style/BannerCardStyle"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/main_banner_battery"
|
|
||||||
android:id="@+id/main_banner_websocket" android:visibility="visible">
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/main_banner_websocket_constraint" android:elevation="5dp">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="28dp"
|
|
||||||
android:layout_height="28dp" app:srcCompat="@drawable/ic_announcement_orange_24dp"
|
|
||||||
android:id="@+id/main_banner_websocket_image"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/main_banner_websocket_text"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/main_banner_websocket_text"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/main_banner_websocket_text"
|
|
||||||
android:layout_marginStart="15dp"/>
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/main_banner_websocket_text"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/main_banner_websocket_text"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
android:layout_marginEnd="15dp" android:layout_marginTop="15dp"
|
|
||||||
app:layout_constraintStart_toEndOf="@+id/main_banner_websocket_image"
|
|
||||||
android:layout_marginStart="10dp"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<androidx.constraintlayout.helper.widget.Flow
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:constraint_referenced_ids="main_banner_websocket_remind_later,main_banner_websocket_dontaskagain,main_banner_websocket_enable" app:layout_constraintTop_toBottomOf="@id/main_banner_websocket_text" app:flow_horizontalAlign="end" app:flow_wrapMode="chain" app:flow_horizontalStyle="packed" android:layout_marginEnd="15dp" android:id="@+id/flow" app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="15dp" app:flow_horizontalBias="1"
|
|
||||||
app:flow_verticalGap="0dp" app:flow_horizontalGap="0dp"/>
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/main_banner_websocket_remind_later"
|
|
||||||
style="@style/Widget.MaterialComponents.Button.TextButton"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/main_banner_websocket_button_remind_later"
|
|
||||||
tools:layout_editor_absoluteX="86dp" tools:layout_editor_absoluteY="83dp"/>
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/main_banner_websocket_dontaskagain"
|
|
||||||
style="@style/Widget.MaterialComponents.Button.TextButton"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/main_banner_websocket_button_dismiss"
|
|
||||||
tools:layout_editor_absoluteX="260dp" tools:layout_editor_absoluteY="83dp"/>
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/main_banner_websocket_enable"
|
|
||||||
style="@style/Widget.MaterialComponents.Button.TextButton"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/main_banner_websocket_button_enable_now"
|
|
||||||
tools:layout_editor_absoluteX="253dp" tools:layout_editor_absoluteY="131dp"/>
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
style="@style/BannerCardStyle"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/main_banner_websocket"
|
|
||||||
android:id="@+id/main_banner_websocket_reconnect" android:visibility="visible">
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/main_banner_websocket_reconnect_constraint" android:elevation="5dp">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="28dp"
|
|
||||||
android:layout_height="28dp" app:srcCompat="@drawable/ic_announcement_orange_24dp"
|
|
||||||
android:id="@+id/main_banner_websocket_reconnect_image"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/main_banner_websocket_reconnect_text"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/main_banner_websocket_reconnect_text"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/main_banner_websocket_reconnect_text"
|
|
||||||
android:layout_marginStart="15dp"/>
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/main_banner_websocket_reconnect_text"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/main_banner_websocket_reconnect_text"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
android:layout_marginEnd="15dp" android:layout_marginTop="15dp"
|
|
||||||
app:layout_constraintStart_toEndOf="@+id/main_banner_websocket_reconnect_image"
|
|
||||||
android:layout_marginStart="10dp"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<androidx.constraintlayout.helper.widget.Flow
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:constraint_referenced_ids="main_banner_websocket_reconnect_remind_later,main_banner_websocket_reconnect_dontaskagain,main_banner_websocket_reconnect_enable" app:layout_constraintTop_toBottomOf="@id/main_banner_websocket_reconnect_text" app:flow_horizontalAlign="end" app:flow_wrapMode="chain" app:flow_horizontalStyle="packed" android:layout_marginEnd="15dp" android:id="@+id/flow_reconnect" app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="15dp" app:flow_horizontalBias="1"
|
|
||||||
app:flow_verticalGap="0dp" app:flow_horizontalGap="0dp"/>
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/main_banner_websocket_reconnect_remind_later"
|
|
||||||
style="@style/Widget.MaterialComponents.Button.TextButton"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/main_banner_websocket_reconnect_button_remind_later"
|
|
||||||
tools:layout_editor_absoluteX="86dp" tools:layout_editor_absoluteY="83dp"/>
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/main_banner_websocket_reconnect_dontaskagain"
|
|
||||||
style="@style/Widget.MaterialComponents.Button.TextButton"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/main_banner_websocket_reconnect_button_dismiss"
|
|
||||||
tools:layout_editor_absoluteX="260dp" tools:layout_editor_absoluteY="83dp"/>
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/main_banner_websocket_reconnect_enable"
|
|
||||||
style="@style/Widget.MaterialComponents.Button.TextButton"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/main_banner_websocket_reconnect_button_enable_now"
|
|
||||||
tools:layout_editor_absoluteX="253dp" tools:layout_editor_absoluteY="131dp"/>
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/main_subscriptions_list"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:visibility="visible"
|
|
||||||
android:clickable="true"
|
|
||||||
android:focusable="true"
|
|
||||||
android:paddingTop="5dp"
|
|
||||||
android:paddingBottom="5dp"
|
|
||||||
android:clipToPadding="false"
|
|
||||||
android:background="?android:attr/selectableItemBackground"
|
|
||||||
app:layoutManager="LinearLayoutManager"
|
|
||||||
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/main_banner_websocket_reconnect"/>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintBottom_toTopOf="@+id/fab" app:layout_constraintStart_toStartOf="parent"
|
|
||||||
android:id="@+id/main_no_subscriptions" android:visibility="gone">
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content" app:srcCompat="@drawable/ic_sms_gray_48dp"
|
|
||||||
android:id="@+id/main_no_subscriptions_image"/>
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/main_no_subscriptions_text"
|
|
||||||
android:text="@string/main_no_subscriptions_text"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
|
|
||||||
android:padding="10dp" android:gravity="center_horizontal"
|
|
||||||
android:paddingStart="50dp" android:paddingEnd="50dp"/>
|
|
||||||
<TextView
|
|
||||||
android:text="@string/main_how_to_intro"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/main_how_to_intro"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:layout_marginStart="50dp"
|
|
||||||
android:layout_marginEnd="50dp"/>
|
|
||||||
<TextView
|
|
||||||
android:text="@string/main_how_to_link"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/main_how_to_link"
|
|
||||||
android:layout_marginTop="7dp"
|
|
||||||
android:layout_marginStart="50dp"
|
|
||||||
android:layout_marginEnd="50dp"
|
|
||||||
android:linksClickable="true"
|
|
||||||
android:autoLink="web"/>
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
|
||||||
android:id="@+id/fab"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_margin="24dp"
|
|
||||||
android:contentDescription="@string/main_add_button_description"
|
|
||||||
android:src="@drawable/ic_add_black_24dp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
style="@style/FloatingActionButton"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<com.google.android.material.appbar.AppBarLayout
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
android:id="@+id/toolbar_layout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:animateLayoutChanges="true"
|
|
||||||
android:fitsSystemWindows="true"
|
|
||||||
app:liftOnScroll="false">
|
|
||||||
|
|
||||||
<com.google.android.material.appbar.MaterialToolbar
|
|
||||||
android:id="@+id/toolbar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="?attr/actionBarSize" />
|
|
||||||
|
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
style="?attr/borderlessButtonStyle"
|
|
||||||
android:id="@+id/button"
|
|
||||||
android:text="Button"
|
|
||||||
android:layout_margin="0dp"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:minWidth="0dp"
|
|
||||||
android:minHeight="0dp"
|
|
||||||
android:insetBottom="0dp"
|
|
||||||
android:insetTop="0dp"
|
|
||||||
android:insetLeft="0dp"
|
|
||||||
android:insetRight="0dp"
|
|
||||||
android:paddingStart="8dp"
|
|
||||||
android:paddingEnd="8dp"
|
|
||||||
android:paddingTop="8dp"
|
|
||||||
android:paddingBottom="8dp"
|
|
||||||
android:textSize="14sp"
|
|
||||||
/>
|
|
||||||
|
|
@ -1,342 +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"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:background="?attr/colorSurface"
|
|
||||||
android:fitsSystemWindows="true">
|
|
||||||
|
|
||||||
<com.google.android.material.appbar.AppBarLayout
|
|
||||||
android:id="@+id/add_dialog_app_bar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="?attr/colorSurface"
|
|
||||||
app:elevation="0dp"
|
|
||||||
app:liftOnScroll="false">
|
|
||||||
|
|
||||||
<com.google.android.material.appbar.MaterialToolbar
|
|
||||||
android:id="@+id/add_dialog_toolbar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="?attr/actionBarSize"
|
|
||||||
android:background="?attr/colorSurface"
|
|
||||||
android:paddingStart="0dp"
|
|
||||||
android:paddingEnd="12dp"
|
|
||||||
app:navigationIcon="@drawable/ic_close_white_24dp"
|
|
||||||
app:navigationIconTint="?attr/colorOnSurface"
|
|
||||||
app:title="@string/add_dialog_title"
|
|
||||||
app:titleTextColor="?attr/colorOnSurface"
|
|
||||||
app:menu="@menu/menu_add_dialog" />
|
|
||||||
|
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:paddingHorizontal="?dialogPreferredPadding"
|
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
android:id="@+id/add_dialog_subscribe_view"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/add_dialog_subscribe_description"
|
|
||||||
android:text="@string/add_dialog_description_below"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:paddingStart="4dp"
|
|
||||||
android:paddingEnd="4dp"
|
|
||||||
android:paddingTop="16dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"/>
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
style="?android:attr/progressBarStyle"
|
|
||||||
android:layout_width="24dp"
|
|
||||||
android:layout_height="24dp"
|
|
||||||
android:id="@+id/add_dialog_subscribe_progress"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
android:indeterminate="true"
|
|
||||||
android:layout_marginTop="16dp"
|
|
||||||
android:visibility="gone"/>
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
android:id="@+id/add_dialog_subscribe_topic_layout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_description"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
android:layout_marginTop="10dp">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:id="@+id/add_dialog_subscribe_topic_text"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:hint="@string/add_dialog_topic_name_hint"
|
|
||||||
android:importantForAutofill="no"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:inputType="text"
|
|
||||||
android:maxLength="64"/>
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<CheckBox
|
|
||||||
android:id="@+id/add_dialog_subscribe_use_another_server_checkbox"
|
|
||||||
android:text="@string/add_dialog_use_another_server"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="-3dp"
|
|
||||||
android:layout_marginTop="-3dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_topic_layout"/>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/add_dialog_subscribe_use_another_server_description"
|
|
||||||
android:text="@string/add_dialog_use_another_server_description"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="-5dp"
|
|
||||||
android:paddingStart="4dp"
|
|
||||||
android:paddingEnd="4dp"
|
|
||||||
android:paddingTop="0dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_use_another_server_checkbox" />
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox.Dense.ExposedDropdownMenu"
|
|
||||||
android:id="@+id/add_dialog_subscribe_base_url_layout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_margin="0dp"
|
|
||||||
android:padding="0dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:endIconMode="custom"
|
|
||||||
app:hintEnabled="false"
|
|
||||||
app:boxBackgroundColor="@null"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_use_another_server_description">
|
|
||||||
|
|
||||||
<AutoCompleteTextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/add_dialog_subscribe_base_url_text"
|
|
||||||
android:hint="@string/app_base_url"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:layout_marginTop="4dp"
|
|
||||||
android:layout_marginBottom="10dp"
|
|
||||||
android:inputType="textNoSuggestions"
|
|
||||||
android:paddingStart="0dp"
|
|
||||||
android:paddingEnd="0dp"
|
|
||||||
android:paddingTop="5dp"
|
|
||||||
android:paddingBottom="5dp"
|
|
||||||
android:layout_marginStart="4dp"
|
|
||||||
android:layout_marginEnd="4dp"
|
|
||||||
android:textAppearance="?android:attr/textAppearanceMedium"/>
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/add_dialog_subscribe_instant_delivery_box"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="-3dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_base_url_layout">
|
|
||||||
|
|
||||||
<CheckBox
|
|
||||||
android:id="@+id/add_dialog_subscribe_instant_delivery_checkbox"
|
|
||||||
android:text="@string/add_dialog_instant_delivery"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="-8dp"
|
|
||||||
android:layout_marginBottom="-5dp"
|
|
||||||
android:layout_marginStart="-3dp"/>
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="24dp"
|
|
||||||
android:layout_height="24dp"
|
|
||||||
android:layout_marginTop="3dp"
|
|
||||||
android:paddingTop="3dp"
|
|
||||||
android:id="@+id/add_dialog_subscribe_instant_image"
|
|
||||||
app:srcCompat="@drawable/ic_bolt_gray_24dp"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/main_item_text"
|
|
||||||
app:layout_constraintEnd_toStartOf="@+id/main_item_date"/>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/add_dialog_subscribe_instant_delivery_description"
|
|
||||||
android:text="@string/add_dialog_instant_delivery_description"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:paddingStart="4dp"
|
|
||||||
android:paddingEnd="4dp"
|
|
||||||
android:paddingTop="0dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_instant_delivery_box"/>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/add_dialog_subscribe_foreground_description"
|
|
||||||
android:text="@string/add_dialog_foreground_description"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:paddingStart="4dp"
|
|
||||||
android:paddingEnd="4dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_instant_delivery_description"/>
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/add_dialog_subscribe_error_text_image"
|
|
||||||
android:layout_width="20dp"
|
|
||||||
android:layout_height="20dp"
|
|
||||||
android:layout_marginTop="1dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:srcCompat="@drawable/ic_error_red_24dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="@id/add_dialog_subscribe_error_text"/>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/add_dialog_subscribe_error_text"
|
|
||||||
android:text="Unable to resolve host example.com"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="5dp"
|
|
||||||
android:paddingStart="4dp"
|
|
||||||
android:paddingEnd="4dp"
|
|
||||||
android:textAppearance="@style/DangerText"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_foreground_description"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/add_dialog_subscribe_error_text_image"
|
|
||||||
tools:visibility="gone"/>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:id="@+id/add_dialog_login_view"
|
|
||||||
android:visibility="gone">
|
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:text="@string/add_dialog_login_description"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/add_dialog_login_description"
|
|
||||||
android:paddingStart="4dp"
|
|
||||||
android:paddingTop="16dp"
|
|
||||||
android:paddingEnd="4dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
style="?android:attr/progressBarStyle"
|
|
||||||
android:layout_width="25dp"
|
|
||||||
android:layout_height="25dp"
|
|
||||||
android:id="@+id/add_dialog_login_progress"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
android:indeterminate="true"
|
|
||||||
android:layout_marginTop="16dp"
|
|
||||||
android:visibility="gone"/>
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
android:id="@+id/add_dialog_login_username_layout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintTop_toBottomOf="@+id/add_dialog_login_description"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
android:layout_marginTop="10dp">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:id="@+id/add_dialog_login_username"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:hint="@string/add_dialog_login_username_hint"
|
|
||||||
android:importantForAutofill="no"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:inputType="text"
|
|
||||||
android:maxLength="64"/>
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
|
||||||
android:id="@+id/add_dialog_login_password_layout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_login_username_layout"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
android:layout_marginTop="10dp">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:id="@+id/add_dialog_login_password"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:hint="@string/add_dialog_login_password_hint"
|
|
||||||
android:importantForAutofill="no"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:inputType="textPassword"/>
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="20dp"
|
|
||||||
android:layout_height="20dp"
|
|
||||||
android:id="@+id/add_dialog_login_error_text_image"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:srcCompat="@drawable/ic_error_red_24dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/add_dialog_login_error_text"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/add_dialog_login_error_text"/>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/add_dialog_login_error_text"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Login failed. User not authorized."
|
|
||||||
android:paddingStart="4dp"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/add_dialog_login_password_layout"
|
|
||||||
android:paddingEnd="4dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
android:textAppearance="@style/DangerText"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/add_dialog_login_error_text_image"
|
|
||||||
tools:visibility="visible"/>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:id="@android:id/text1"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
|
||||||
android:textColor="?android:attr/textColorAlertDialogListItem"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:paddingStart="7dp"
|
|
||||||
android:paddingEnd="7dp"
|
|
||||||
android:paddingTop="7dp"
|
|
||||||
android:paddingBottom="7dp"
|
|
||||||
android:ellipsize="marquee"
|
|
||||||
/>
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.cardview.widget.CardView 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" style="@style/CardView"
|
|
||||||
android:id="@+id/detail_item_card"
|
|
||||||
android:background="?android:attr/selectableItemBackground"
|
|
||||||
android:clickable="true"
|
|
||||||
android:focusable="true"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="0dp"
|
|
||||||
android:layout_marginStart="6dp"
|
|
||||||
android:layout_marginEnd="6dp"
|
|
||||||
android:layout_marginBottom="1dp"
|
|
||||||
android:padding="3dp"
|
|
||||||
app:cardCornerRadius="3dp"
|
|
||||||
app:cardElevation="2dp"
|
|
||||||
app:cardMaxElevation="2dp"
|
|
||||||
app:cardPreventCornerOverlap="true"
|
|
||||||
app:cardUseCompatPadding="true">
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:id="@+id/detail_item_layout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:background="?android:attr/selectableItemBackground"
|
|
||||||
android:focusable="true"
|
|
||||||
android:paddingBottom="6dp" android:paddingTop="6dp" android:paddingEnd="6dp">
|
|
||||||
<TextView
|
|
||||||
android:text="Sun, October 31, 2021, 10:43:12"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/detail_item_date_text"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
android:layout_marginTop="5dp" app:layout_constraintStart_toStartOf="parent"
|
|
||||||
android:layout_marginStart="12dp"/>
|
|
||||||
<TextView
|
|
||||||
android:layout_width="10dp"
|
|
||||||
android:layout_height="10dp" android:id="@+id/detail_item_new_dot"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:background="@drawable/ic_circle"
|
|
||||||
android:gravity="center"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/detail_item_date_text"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/detail_item_date_text"
|
|
||||||
android:layout_marginTop="1dp"
|
|
||||||
app:layout_constraintStart_toEndOf="@id/detail_item_priority_image"
|
|
||||||
android:layout_marginStart="5dp"/>
|
|
||||||
<ImageButton
|
|
||||||
android:layout_width="28dp"
|
|
||||||
android:layout_height="26dp" app:srcCompat="@drawable/ic_more_horiz_gray_24dp"
|
|
||||||
android:id="@+id/detail_item_menu_button"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
android:background="?android:attr/selectableItemBackground" android:paddingTop="-5dp"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="3dp"/>
|
|
||||||
<TextView
|
|
||||||
android:text="This is a very very very long message. It could be as long as 1024 charaters, which is a lot more than you'd think. No, really so far this message is barely 180 characters long. I can't believe how long 1024 bytes are. This is outrageous. Oh you know what, I think I won't type the whole thing. This seems a little too long for a sample text. Well, anyway, it was nice chatting. So far this message is about 400 bytes long. So maybe just double what you see and that's that."
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/detail_item_message_text"
|
|
||||||
android:textColor="?android:attr/textColorPrimary"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
|
|
||||||
android:autoLink="web|phone|email"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/detail_item_title_text"
|
|
||||||
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="12dp"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_image" app:layout_constraintEnd_toStartOf="@id/detail_item_icon" android:layout_marginEnd="6dp"/>
|
|
||||||
<TextView
|
|
||||||
android:text="This is an optional title. It can also be a little longer but not too long."
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/detail_item_title_text"
|
|
||||||
android:textColor="?android:attr/textColorPrimary"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
|
|
||||||
android:autoLink="web"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
android:layout_marginStart="12dp" android:textStyle="bold"
|
|
||||||
app:layout_constraintTop_toBottomOf="@+id/detail_item_date_text" app:layout_constraintEnd_toStartOf="@id/detail_item_icon" android:layout_marginEnd="6dp" tools:layout_constraintEnd_toStartOf="@id/detail_item_icon"/>
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="16dp"
|
|
||||||
android:layout_height="16dp" app:srcCompat="@drawable/ic_priority_5_24dp"
|
|
||||||
android:id="@+id/detail_item_priority_image"
|
|
||||||
app:layout_constraintStart_toEndOf="@+id/detail_item_date_text"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/detail_item_date_text"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/detail_item_date_text" android:layout_marginStart="5dp"/>
|
|
||||||
<com.google.android.material.imageview.ShapeableImageView
|
|
||||||
android:layout_width="fill_parent"
|
|
||||||
android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp"
|
|
||||||
android:id="@+id/detail_item_attachment_image" app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/detail_item_message_text"
|
|
||||||
android:layout_marginStart="12dp" android:layout_marginEnd="6dp"
|
|
||||||
android:scaleType="centerCrop"
|
|
||||||
android:adjustViewBounds="true" android:maxHeight="150dp" android:layout_marginTop="7dp"
|
|
||||||
app:shapeAppearanceOverlay="@style/roundedCornersImageView" android:visibility="visible"
|
|
||||||
android:layout_marginBottom="3dp" app:layout_constraintBottom_toTopOf="@id/detail_item_tags_text"/>
|
|
||||||
<TextView
|
|
||||||
android:text="Tags: ssh, zfs"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/detail_item_tags_text"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
|
||||||
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="12dp"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="6dp"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/detail_item_attachment_image"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/detail_item_attachment_file_box"
|
|
||||||
app:layout_constraintHorizontal_bias="0.0" android:layout_marginTop="2dp"
|
|
||||||
/>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content" app:layout_constraintTop_toBottomOf="@id/detail_item_tags_text"
|
|
||||||
android:id="@+id/detail_item_attachment_file_box" app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent" android:layout_marginStart="12dp" android:layout_marginEnd="6dp"
|
|
||||||
android:visibility="visible" android:layout_marginTop="2dp"
|
|
||||||
android:background="?android:attr/selectableItemBackground"
|
|
||||||
android:clickable="true" android:focusable="true" android:padding="4dp" android:paddingStart="0dp">
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp"
|
|
||||||
android:id="@+id/detail_item_attachment_file_icon" app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintEnd_toStartOf="@+id/detail_item_attachment_file_info" android:layout_marginEnd="5dp"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
/>
|
|
||||||
<TextView
|
|
||||||
android:text="attachment.jpg\n58 MB, not downloaded, expires 1/2/2022 10:30 PM"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:id="@+id/detail_item_attachment_file_info"
|
|
||||||
android:textColor="?android:attr/textColorPrimary"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
|
||||||
app:layout_constraintStart_toEndOf="@+id/detail_item_attachment_file_icon"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/detail_item_attachment_file_icon"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/detail_item_attachment_file_icon"/>
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/detail_item_attachment_file_box"
|
|
||||||
android:id="@+id/detail_item_actions_wrapper"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
android:layout_marginEnd="4dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
android:padding="0dp" android:layout_marginStart="4dp" android:layout_marginTop="4dp">
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:text="Bing it"
|
|
||||||
style="?attr/borderlessButtonStyle"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content" android:id="@+id/button2" tools:layout_editor_absoluteY="4dp" tools:layout_editor_absoluteX="171dp" android:textSize="14sp" tools:visibility="visible"/>
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:text="Google it"
|
|
||||||
style="?attr/borderlessButtonStyle"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content" android:id="@+id/button3" tools:visibility="visible" tools:layout_editor_absoluteY="52dp" tools:layout_editor_absoluteX="4dp" android:textSize="14sp"/>
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:text="DuckDuckGo it"
|
|
||||||
style="?attr/borderlessButtonStyle"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content" android:id="@+id/button1" tools:visibility="visible" tools:layout_editor_absoluteY="4dp" tools:layout_editor_absoluteX="4dp" android:textSize="14sp" android:layout_margin="0dp"/>
|
|
||||||
<androidx.constraintlayout.helper.widget.Flow
|
|
||||||
android:id="@+id/detail_item_actions_flow"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:flow_wrapMode="chain2"
|
|
||||||
app:flow_horizontalStyle="packed"
|
|
||||||
app:flow_verticalBias="0"
|
|
||||||
app:flow_verticalGap="0dp"
|
|
||||||
app:flow_verticalStyle="packed"
|
|
||||||
app:flow_verticalAlign="top"
|
|
||||||
app:flow_horizontalBias="0"
|
|
||||||
app:flow_horizontalGap="0dp"
|
|
||||||
app:flow_horizontalAlign="start"
|
|
||||||
app:flow_firstHorizontalBias="0"
|
|
||||||
app:flow_firstVerticalBias="0"
|
|
||||||
app:flow_firstHorizontalStyle="packed"
|
|
||||||
app:flow_firstVerticalStyle="packed"
|
|
||||||
app:flow_maxElementsWrap="3"
|
|
||||||
android:layout_margin="0dp"
|
|
||||||
android:padding="0dp"
|
|
||||||
app:constraint_referenced_ids="button1,button2,button3"/>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="4dp"
|
|
||||||
android:id="@+id/detail_item_padding_bottom"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintTop_toBottomOf="@id/detail_item_actions_wrapper" app:layout_constraintBottom_toBottomOf="parent"/>
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
app:srcCompat="@drawable/ic_notification"
|
|
||||||
android:id="@+id/detail_item_icon"
|
|
||||||
android:visibility="visible"
|
|
||||||
android:maxHeight="40dp"
|
|
||||||
android:maxWidth="40dp"
|
|
||||||
android:adjustViewBounds="true"
|
|
||||||
android:scaleType="fitStart"
|
|
||||||
android:padding="0dp"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/detail_item_date_text"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/detail_item_message_text"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/detail_item_menu_button"
|
|
||||||
android:layout_marginEnd="6dp"/>
|
|
||||||
<androidx.constraintlayout.widget.Guideline android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/guideline2" app:layout_constraintGuide_begin="27dp" android:orientation="horizontal"/>
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
</androidx.cardview.widget.CardView>
|
|
||||||
|
|
@ -1,83 +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:foreground="?android:attr/selectableItemBackground"
|
|
||||||
android:orientation="horizontal" android:clickable="true"
|
|
||||||
android:focusable="true" android:paddingEnd="18dp"
|
|
||||||
android:paddingStart="18dp">
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="35dp"
|
|
||||||
android:layout_height="35dp" app:srcCompat="@drawable/ic_sms_gray_24dp"
|
|
||||||
android:id="@+id/main_item_image" app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
android:layout_marginTop="17dp" android:scaleType="fitStart"/>
|
|
||||||
<TextView
|
|
||||||
android:text="ntfy.sh/example"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content" android:id="@+id/main_item_text"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toTopOf="@+id/main_item_status"
|
|
||||||
android:layout_marginStart="12dp" app:layout_constraintStart_toEndOf="@+id/main_item_image"
|
|
||||||
app:layout_constraintVertical_bias="0.0" android:textAppearance="@style/TextAppearance.AppCompat.Medium"
|
|
||||||
android:textColor="?android:attr/textColorPrimary" android:layout_marginTop="10dp"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/main_item_connection_error_image"/>
|
|
||||||
<TextView
|
|
||||||
android:text="89 notifications, reconnecting ... This may wrap in the case of UnifiedPush"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content" android:id="@+id/main_item_status"
|
|
||||||
app:layout_constraintStart_toStartOf="@+id/main_item_text"
|
|
||||||
app:layout_constraintTop_toBottomOf="@+id/main_item_text" app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
android:layout_marginBottom="10dp" app:layout_constrainedWidth="true"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/main_item_new" android:layout_marginEnd="10dp"/>
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="20dp"
|
|
||||||
android:layout_height="24dp" app:srcCompat="@drawable/ic_warning_amber_24dp"
|
|
||||||
android:id="@+id/main_item_connection_error_image"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/main_item_text"
|
|
||||||
app:layout_constraintEnd_toStartOf="@+id/main_item_notification_disabled_until_image"
|
|
||||||
android:paddingTop="3dp" android:layout_marginEnd="3dp"
|
|
||||||
android:visibility="gone"/>
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="20dp"
|
|
||||||
android:layout_height="24dp" app:srcCompat="@drawable/ic_notifications_off_time_gray_outline_24dp"
|
|
||||||
android:id="@+id/main_item_notification_disabled_until_image"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/main_item_text"
|
|
||||||
app:layout_constraintEnd_toStartOf="@+id/main_item_notification_disabled_forever_image"
|
|
||||||
android:paddingTop="3dp" android:layout_marginEnd="3dp"/>
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="20dp"
|
|
||||||
android:layout_height="24dp" app:srcCompat="@drawable/ic_notifications_off_gray_outline_24dp"
|
|
||||||
android:id="@+id/main_item_notification_disabled_forever_image"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/main_item_notification_disabled_until_image"
|
|
||||||
app:layout_constraintEnd_toStartOf="@+id/main_item_instant_image" android:paddingTop="3dp"
|
|
||||||
android:layout_marginEnd="3dp"/>
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="20dp"
|
|
||||||
android:layout_height="24dp" app:srcCompat="@drawable/ic_bolt_gray_24dp"
|
|
||||||
android:id="@+id/main_item_instant_image"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/main_item_notification_disabled_forever_image"
|
|
||||||
app:layout_constraintEnd_toStartOf="@+id/main_item_date" android:paddingTop="3dp"/>
|
|
||||||
<TextView
|
|
||||||
android:text="10:13"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content" android:id="@+id/main_item_date"
|
|
||||||
app:layout_constraintTop_toTopOf="@+id/main_item_instant_image"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
android:paddingTop="2dp"/>
|
|
||||||
<TextView
|
|
||||||
android:text="99+"
|
|
||||||
android:layout_width="24dp"
|
|
||||||
android:layout_height="24dp" android:id="@+id/main_item_new"
|
|
||||||
android:layout_marginTop="3dp"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:background="@drawable/ic_circle"
|
|
||||||
android:gravity="center"
|
|
||||||
android:textColor="@android:color/white"
|
|
||||||
app:layout_constraintTop_toBottomOf="@+id/main_item_date"
|
|
||||||
app:layout_constraintEnd_toEndOf="@+id/main_item_date"
|
|
||||||
app:layout_constraintStart_toEndOf="@+id/main_item_instant_image"
|
|
||||||
android:textSize="10sp" android:textStyle="bold"/>
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<LinearLayout 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:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:paddingHorizontal="16dp"
|
|
||||||
android:paddingVertical="12dp"
|
|
||||||
android:minHeight="48dp">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/priority_icon"
|
|
||||||
android:layout_width="20dp"
|
|
||||||
android:layout_height="20dp"
|
|
||||||
android:layout_marginEnd="12dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
app:srcCompat="@drawable/ic_priority_3_24dp"/>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/priority_text"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:textSize="16sp"
|
|
||||||
android:textColor="?android:attr/textColorPrimary"/>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
@ -1,83 +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:background="@color/detail_activity_background"
|
|
||||||
android:paddingStart="8dp"
|
|
||||||
android:paddingEnd="8dp"
|
|
||||||
android:paddingTop="6dp"
|
|
||||||
android:paddingBottom="6dp">
|
|
||||||
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
android:id="@+id/message_bar_card"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
app:cardCornerRadius="16dp"
|
|
||||||
app:cardElevation="0dp"
|
|
||||||
app:strokeWidth="0dp"
|
|
||||||
app:cardBackgroundColor="?android:attr/colorBackground"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
|
||||||
app:layout_constraintEnd_toStartOf="@id/message_bar_publish_button"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
android:layout_marginEnd="6dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/message_bar_expand_button"
|
|
||||||
android:layout_width="40dp"
|
|
||||||
android:layout_height="40dp"
|
|
||||||
android:src="@drawable/ic_expand_less_gray_24dp"
|
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
|
||||||
android:contentDescription="@string/message_bar_expand_button_description"
|
|
||||||
app:tint="?attr/colorOutline"
|
|
||||||
android:layout_marginStart="4dp"/>
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
|
||||||
android:id="@+id/message_bar_text"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:hint="@string/message_bar_hint"
|
|
||||||
android:textColorHint="?attr/colorOutline"
|
|
||||||
android:inputType="textMultiLine|textCapSentences"
|
|
||||||
android:minHeight="48dp"
|
|
||||||
android:maxLines="4"
|
|
||||||
android:background="@null"
|
|
||||||
android:paddingStart="4dp"
|
|
||||||
android:paddingEnd="16dp"
|
|
||||||
android:paddingTop="12dp"
|
|
||||||
android:paddingBottom="12dp"
|
|
||||||
android:importantForAutofill="no"/>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
|
||||||
android:id="@+id/message_bar_publish_button"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:src="@drawable/ic_send_white_24dp"
|
|
||||||
android:contentDescription="@string/message_bar_publish_button_description"
|
|
||||||
android:clickable="true"
|
|
||||||
android:focusable="true"
|
|
||||||
app:fabSize="mini"
|
|
||||||
app:fabCustomSize="48dp"
|
|
||||||
app:maxImageSize="24dp"
|
|
||||||
app:tint="?attr/colorOnPrimary"
|
|
||||||
app:backgroundTint="?attr/colorPrimary"
|
|
||||||
app:rippleColor="#40FFFFFF"
|
|
||||||
app:elevation="2dp"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
<item
|
|
||||||
android:id="@+id/add_dialog_action_button"
|
|
||||||
android:title="@string/add_dialog_button_subscribe"
|
|
||||||
android:enabled="false"
|
|
||||||
app:showAsAction="always" />
|
|
||||||
</menu>
|
|
||||||
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<menu
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_menu_connection_error"
|
|
||||||
android:icon="@drawable/ic_warning_white_24dp"
|
|
||||||
android:title="@string/main_menu_connection_error"
|
|
||||||
android:visible="false"
|
|
||||||
app:showAsAction="always" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_menu_notifications_enabled"
|
|
||||||
android:icon="@drawable/ic_notifications_white_24dp"
|
|
||||||
android:title="@string/detail_menu_notifications_enabled"
|
|
||||||
app:showAsAction="ifRoom" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_menu_notifications_disabled_until"
|
|
||||||
android:icon="@drawable/ic_notifications_off_time_white_outline_24dp"
|
|
||||||
android:title="@string/detail_menu_notifications_disabled_forever"
|
|
||||||
app:showAsAction="ifRoom" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_menu_notifications_disabled_forever"
|
|
||||||
android:icon="@drawable/ic_notifications_off_white_outline_24dp"
|
|
||||||
android:title="@string/detail_menu_notifications_disabled_forever"
|
|
||||||
app:showAsAction="ifRoom" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_menu_enable_instant"
|
|
||||||
android:icon="@drawable/ic_bolt_outline_white_24dp"
|
|
||||||
android:title="@string/detail_menu_enable_instant"
|
|
||||||
app:showAsAction="ifRoom" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_menu_disable_instant"
|
|
||||||
android:icon="@drawable/ic_bolt_white_24dp"
|
|
||||||
android:title="@string/detail_menu_disable_instant"
|
|
||||||
app:showAsAction="ifRoom" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_menu_settings"
|
|
||||||
android:title="@string/detail_menu_settings" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_menu_copy_url"
|
|
||||||
android:title="@string/detail_menu_copy_url" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_menu_clear"
|
|
||||||
android:title="@string/detail_menu_clear" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_menu_test"
|
|
||||||
android:title="@string/detail_menu_test" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_menu_unsubscribe"
|
|
||||||
android:title="@string/detail_menu_unsubscribe" />
|
|
||||||
</menu>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<menu
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_action_mode_copy"
|
|
||||||
android:icon="@drawable/ic_content_copy_white_24dp"
|
|
||||||
android:title="@string/common_button_copy"
|
|
||||||
app:iconTint="@android:color/white" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/detail_action_mode_delete"
|
|
||||||
android:icon="@drawable/ic_delete_white_20dp"
|
|
||||||
android:title="@string/detail_action_mode_menu_delete"
|
|
||||||
app:iconTint="@android:color/white" />
|
|
||||||
</menu>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item android:id="@+id/detail_item_menu_download" android:title="@string/detail_item_menu_download"/>
|
|
||||||
<item android:id="@+id/detail_item_menu_cancel" android:title="@string/detail_item_menu_cancel"/>
|
|
||||||
<item android:id="@+id/detail_item_menu_open" android:title="@string/detail_item_menu_open"/>
|
|
||||||
<item android:id="@+id/detail_item_menu_delete" android:title="@string/detail_item_menu_delete"/>
|
|
||||||
<item android:id="@+id/detail_item_menu_save_file" android:title="@string/detail_item_menu_save_file"/>
|
|
||||||
<item android:id="@+id/detail_item_menu_copy_url" android:title="@string/detail_item_menu_copy_url"/>
|
|
||||||
<item android:id="@+id/detail_item_menu_copy_contents" android:title="@string/detail_item_menu_copy_contents"/>
|
|
||||||
</menu>
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<menu
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
<item
|
|
||||||
android:id="@+id/main_menu_connection_error"
|
|
||||||
android:icon="@drawable/ic_warning_white_24dp"
|
|
||||||
android:title="@string/main_menu_connection_error"
|
|
||||||
android:visible="false"
|
|
||||||
app:showAsAction="always" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/main_menu_notifications_enabled"
|
|
||||||
android:icon="@drawable/ic_notifications_white_24dp"
|
|
||||||
android:title="@string/main_menu_notifications_enabled"
|
|
||||||
app:showAsAction="ifRoom" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/main_menu_notifications_disabled_until"
|
|
||||||
android:icon="@drawable/ic_notifications_off_time_white_outline_24dp"
|
|
||||||
android:title="@string/main_menu_notifications_disabled_forever"
|
|
||||||
app:showAsAction="ifRoom" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/main_menu_notifications_disabled_forever"
|
|
||||||
android:icon="@drawable/ic_notifications_off_white_outline_24dp"
|
|
||||||
android:title="@string/detail_menu_notifications_disabled_forever"
|
|
||||||
app:showAsAction="ifRoom" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/main_menu_settings"
|
|
||||||
android:title="@string/main_menu_settings_title" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/main_menu_docs"
|
|
||||||
android:title="@string/main_menu_docs_title" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/main_menu_rate"
|
|
||||||
android:title="@string/main_menu_rate_title" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/main_menu_report_bug"
|
|
||||||
android:title="@string/main_menu_report_bug_title" />
|
|
||||||
</menu>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue