├── .github
└── workflows
│ ├── commit.yml
│ └── release.yml
├── .gitignore
├── LICENSE
├── README.md
├── apistub
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ └── java
│ └── android
│ └── net
│ └── IConnectivityManager.java
├── app
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── aidl
│ └── moe
│ │ └── reimu
│ │ └── catshare
│ │ └── IMacAddressService.aidl
│ ├── ic_launcher-playstore.png
│ ├── java
│ └── moe
│ │ └── reimu
│ │ └── catshare
│ │ ├── AppSettings.kt
│ │ ├── BleSecurity.kt
│ │ ├── FakeTrustManager.kt
│ │ ├── MainActivity.kt
│ │ ├── MyApplication.kt
│ │ ├── SettingsActivity.kt
│ │ ├── ShareActivity.kt
│ │ ├── StartReceiverActivity.kt
│ │ ├── exceptions
│ │ ├── CancelledByUserException.kt
│ │ └── ExceptionWithMessage.kt
│ │ ├── models
│ │ ├── DeviceInfo.kt
│ │ ├── DiscoveredDevice.kt
│ │ ├── FileInfo.kt
│ │ ├── P2pInfo.kt
│ │ ├── ReceivedFile.kt
│ │ ├── TaskInfo.kt
│ │ └── WebSocketMessage.kt
│ │ ├── services
│ │ ├── BaseP2pService.kt
│ │ ├── GattServerService.kt
│ │ ├── MacAddressService.kt
│ │ ├── P2pReceiverService.kt
│ │ ├── P2pSenderService.kt
│ │ └── ReceiverTileService.kt
│ │ ├── ui
│ │ ├── Cards.kt
│ │ └── theme
│ │ │ ├── Color.kt
│ │ │ ├── Theme.kt
│ │ │ └── Type.kt
│ │ └── utils
│ │ ├── BleUtils.kt
│ │ ├── CoroutineUtils.kt
│ │ ├── DeviceUtils.kt
│ │ ├── JsonUtils.kt
│ │ ├── LogUtils.kt
│ │ ├── NotificationUtils.kt
│ │ ├── P2pUtils.kt
│ │ ├── PermissionUtils.kt
│ │ ├── ProgressCounter.kt
│ │ ├── ServiceState.kt
│ │ ├── ShizukuUtils.kt
│ │ ├── WsUtils.kt
│ │ └── ZipPathValidatorCallback.kt
│ └── res
│ ├── drawable
│ ├── ic_bluetooth_searching.xml
│ ├── ic_close.xml
│ ├── ic_done.xml
│ ├── ic_download.xml
│ ├── ic_downloading.xml
│ ├── ic_launcher_foreground.xml
│ ├── ic_launcher_monochrome.xml
│ ├── ic_share.xml
│ ├── ic_upload_file.xml
│ └── ic_warning.xml
│ ├── mipmap-anydpi-v26
│ ├── ic_launcher.xml
│ └── ic_launcher_round.xml
│ ├── mipmap-hdpi
│ ├── ic_launcher.webp
│ └── ic_launcher_round.webp
│ ├── mipmap-mdpi
│ ├── ic_launcher.webp
│ └── ic_launcher_round.webp
│ ├── mipmap-xhdpi
│ ├── ic_launcher.webp
│ └── ic_launcher_round.webp
│ ├── mipmap-xxhdpi
│ ├── ic_launcher.webp
│ └── ic_launcher_round.webp
│ ├── mipmap-xxxhdpi
│ ├── ic_launcher.webp
│ └── ic_launcher_round.webp
│ ├── values-zh-rCN
│ └── strings.xml
│ ├── values
│ ├── colors.xml
│ ├── ic_launcher_background.xml
│ ├── strings.xml
│ └── themes.xml
│ └── xml
│ ├── backup_rules.xml
│ ├── data_extraction_rules.xml
│ └── provider_paths.xml
├── build.gradle.kts
├── fastlane
└── metadata
│ └── android
│ ├── en-US
│ ├── full_description.txt
│ ├── images
│ │ └── icon.png
│ └── short_description.txt
│ └── zh-CN
│ ├── full_description.txt
│ └── short_description.txt
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle.kts
/.github/workflows/commit.yml:
--------------------------------------------------------------------------------
1 | name: Debug Build
2 |
3 | on:
4 | push:
5 | paths-ignore:
6 | - "**.md"
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v4
14 |
15 | - name: Set up JDK 17
16 | uses: actions/setup-java@v3
17 | with:
18 | java-version: "17"
19 | distribution: "temurin"
20 |
21 | - name: Setup Android SDK
22 | uses: android-actions/setup-android@v3
23 |
24 | - name: Build Application
25 | run: ./gradlew --no-daemon app:assembleDebug :app:lintReportDebug
26 |
27 | - name: Upload Debug APK
28 | uses: actions/upload-artifact@v4
29 | with:
30 | name: app-debug
31 | path: app/build/outputs/apk/debug/app-debug.apk
32 |
33 | - name: Upload Lint Results
34 | uses: actions/upload-artifact@v4
35 | with:
36 | name: lint-results
37 | path: app/build/reports/lint-results.xml
38 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Build Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | permissions:
9 | contents: write
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v4
17 |
18 | - name: Set up JDK 17
19 | uses: actions/setup-java@v3
20 | with:
21 | java-version: "17"
22 | distribution: "temurin"
23 |
24 | - name: Setup Android SDK
25 | uses: android-actions/setup-android@v3
26 |
27 | - name: Extract KeyStore
28 | run: echo "${{ secrets.KEYSTORE }}" | base64 --decode > keystore.jks
29 |
30 | - name: Extract Signing Properties
31 | run: echo "${{ secrets.KEYSTORE_PROPERTIES }}" | base64 --decode > signing.properties
32 |
33 | - name: Build Application
34 | run: ./gradlew --no-daemon assembleRelease lintRelease
35 |
36 | - name: Upload Release APK
37 | uses: actions/upload-artifact@v4
38 | with:
39 | name: app-release
40 | path: app/build/outputs/apk/release/app-release.apk
41 |
42 | - name: Upload Lint Results
43 | uses: actions/upload-artifact@v4
44 | with:
45 | name: lint-results
46 | path: app/build/reports/lint-results-release.xml
47 |
48 | - name: Release
49 | uses: softprops/action-gh-release@v2
50 | with:
51 | files: app/build/outputs/apk/release/app-release.apk
52 | draft: true
53 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea
5 | .DS_Store
6 | /build
7 | /captures
8 | .externalNativeBuild
9 | .cxx
10 | local.properties
11 |
12 | keystore.jks
13 | signing.properties
14 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2025 Midori Kochiya
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CatShare
2 | 类原生 & 海外设备,现已加入互传联盟。
3 |
4 | Android 目前已不再支持非系统应用获取手机的 MAC 地址等无法重置的序列号,但由于各品牌的互传功能通常为系统应用,互传联盟协议将设备的 MAC 地址作为其认证信息的一部分,目前暂时无法绕过。
5 |
6 | 本 App 的 GitHub Release 和 F-Droid 版本签名一致, F-Droid 版本可能相对滞后,可以任意选择。
7 |
8 | [
](https://f-droid.org/packages/moe.reimu.catshare)
11 | [
](https://www.openapk.net/catshare/moe.reimu.catshare/)
14 | [
](https://www.androidfreeware.net/download-catshare-apk.html)
17 |
18 | ## 功能
19 | - [x] 蓝牙发现
20 | - [x] 文件接收
21 | - [x] 文件发送(需要 Shizuku 支持)
22 | - [x] 文本传输(两侧均为 CatShare 时复制至剪贴板,接收方为其他设备时以文本文件形式发送)
23 |
24 | ## 支持设备(已测试)
25 | | 品牌 | 向该设备发送 | 从该设备接收 |
26 | | ----------- | ------------ | ----------------------- |
27 | | 小米 | Y | Y |
28 | | OPPO/一加等 | Y | Y,但发送端提示接收失败 |
29 | | vivo | Y | Y |
30 |
31 | ## 汇报问题
32 |
33 | 你可以在该项目的 issue 区汇报你在使用 CatShare 期间遇到的问题,尽量的,请附上 CatShare 的 adb logcat 日志。
34 |
35 | 通过该命令获取 CatShare 的日志。
36 |
37 | ```shell
38 | adb logcat --pid $(adb shell pidof -s moe.reimu.catshare)
39 | ```
40 |
41 | 建议尽可能完整的截取日志,并注释从什么时候发送或接收内容,尽量使用折叠块语法来包裹日志内容。
42 |
43 | ````markdown
44 |
45 | Details
46 |
47 | ```
48 | 在此处填入日志内容,注意其应被包裹在反括号代码块内
49 | ```
50 |
51 |
52 | ````
53 |
--------------------------------------------------------------------------------
/apistub/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/apistub/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | }
5 |
6 | android {
7 | namespace = "moe.reimu.apistub"
8 | compileSdk = 34
9 |
10 | defaultConfig {
11 | minSdk = 29
12 |
13 | consumerProguardFiles("consumer-rules.pro")
14 | }
15 |
16 | buildTypes {
17 | release {
18 | isMinifyEnabled = false
19 | proguardFiles(
20 | getDefaultProguardFile("proguard-android-optimize.txt"),
21 | "proguard-rules.pro"
22 | )
23 | }
24 | }
25 | compileOptions {
26 | sourceCompatibility = JavaVersion.VERSION_11
27 | targetCompatibility = JavaVersion.VERSION_11
28 | }
29 | kotlinOptions {
30 | jvmTarget = "11"
31 | }
32 | }
33 |
34 | dependencies {
35 | implementation(libs.androidx.core.ktx)
36 | implementation(libs.androidx.appcompat)
37 | implementation(libs.material)
38 | }
--------------------------------------------------------------------------------
/apistub/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/apistub/consumer-rules.pro
--------------------------------------------------------------------------------
/apistub/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/apistub/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/apistub/src/main/java/android/net/IConnectivityManager.java:
--------------------------------------------------------------------------------
1 | package android.net;
2 |
3 | import android.os.Binder;
4 | import android.os.IBinder;
5 | import android.os.IInterface;
6 | import android.os.RemoteException;
7 |
8 | public interface IConnectivityManager extends IInterface {
9 | NetworkInfo[] getAllNetworkInfo() throws RemoteException;
10 |
11 | abstract class Stub extends Binder implements IConnectivityManager {
12 | public static IConnectivityManager asInterface(IBinder obj) {
13 | throw new UnsupportedOperationException();
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import java.util.Properties
2 |
3 | plugins {
4 | alias(libs.plugins.android.application)
5 | alias(libs.plugins.kotlin.android)
6 | alias(libs.plugins.kotlin.compose)
7 | alias(libs.plugins.kotlin.serialization)
8 | alias(libs.plugins.kotlin.parcelize)
9 | }
10 |
11 | android {
12 | namespace = "moe.reimu.catshare"
13 | compileSdk = 35
14 |
15 | defaultConfig {
16 | applicationId = "moe.reimu.catshare"
17 | minSdk = 29
18 | targetSdk = 35
19 | versionCode = 6
20 | versionName = "1.5"
21 | }
22 |
23 | signingConfigs {
24 | create("release") {
25 | file("../signing.properties").let { propFile ->
26 | if (propFile.canRead()) {
27 | val properties = Properties()
28 | properties.load(propFile.inputStream())
29 |
30 | storeFile = file(properties.getProperty("KEYSTORE_FILE"))
31 | storePassword = properties.getProperty("KEYSTORE_PASSWORD")
32 | keyAlias = properties.getProperty("SIGNING_KEY_ALIAS")
33 | keyPassword = properties.getProperty("SIGNING_KEY_PASSWORD")
34 | } else {
35 | println("Unable to read signing.properties")
36 | }
37 | }
38 | }
39 | }
40 |
41 | dependenciesInfo {
42 | // Disables dependency metadata when building APKs.
43 | includeInApk = false
44 | // Disables dependency metadata when building Android App Bundles.
45 | includeInBundle = false
46 | }
47 |
48 | buildTypes {
49 | release {
50 | isMinifyEnabled = true
51 | isShrinkResources = true
52 |
53 | proguardFiles(
54 | getDefaultProguardFile("proguard-android-optimize.txt"),
55 | "proguard-rules.pro"
56 | )
57 |
58 | signingConfig = signingConfigs.findByName("release")
59 | }
60 | debug {
61 | applicationIdSuffix = ".debug"
62 | resValue("string", "app_name", "CatShare (Debug)")
63 | }
64 | }
65 | compileOptions {
66 | sourceCompatibility = JavaVersion.VERSION_11
67 | targetCompatibility = JavaVersion.VERSION_11
68 | }
69 | kotlinOptions {
70 | jvmTarget = "11"
71 | }
72 | buildFeatures {
73 | compose = true
74 | aidl = true
75 | buildConfig = true
76 | }
77 |
78 | packaging {
79 | resources {
80 | excludes += "META-INF/INDEX.LIST"
81 | excludes += "META-INF/*.properties"
82 | }
83 | }
84 | }
85 |
86 | dependencies {
87 | implementation(libs.androidx.core.ktx)
88 | implementation(libs.androidx.lifecycle.runtime.ktx)
89 | implementation(libs.androidx.activity.compose)
90 | implementation(platform(libs.androidx.compose.bom))
91 | implementation(libs.androidx.ui)
92 | implementation(libs.androidx.ui.graphics)
93 | implementation(libs.androidx.ui.tooling.preview)
94 | implementation(libs.androidx.material3)
95 |
96 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
97 | implementation("no.nordicsemi.android.kotlin.ble:client:1.1.0")
98 |
99 | implementation(libs.ktor.client.core)
100 | implementation(libs.ktor.client.okhttp)
101 | implementation(libs.ktor.client.websockets)
102 | implementation(libs.ktor.server.core)
103 | implementation(libs.ktor.server.netty)
104 | implementation(libs.ktor.server.websockets)
105 | implementation(libs.ktor.network.tls.certificates)
106 |
107 | implementation(libs.kotlinx.serialization.json)
108 |
109 | implementation(libs.androidx.appcompat)
110 | implementation(libs.material)
111 | implementation(libs.androidx.activity)
112 |
113 | implementation(libs.shizuku.api)
114 | implementation(libs.shizuku.provider)
115 |
116 | compileOnly(project(":apistub"))
117 | debugImplementation(libs.androidx.ui.tooling)
118 | debugImplementation(libs.androidx.ui.test.manifest)
119 | }
120 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
23 | -dontobfuscate
24 |
25 | -keepattributes LineNumberTable,SourceFile
26 | -renamesourcefileattribute SourceFile
27 | -keepattributes Signature,InnerClasses
28 |
29 | -keep class moe.reimu.catshare.** { *; }
30 |
31 | -keep class io.netty.** { *; }
32 | -keep class io.ktor.** { *; }
33 | -keep class kotlinx.coroutines.** { *; }
34 |
35 |
36 | # Please add these rules to your existing keep rules in order to suppress warnings.
37 | # This is generated automatically by the Android Gradle plugin.
38 | -dontwarn com.aayushatharva.brotli4j.Brotli4jLoader
39 | -dontwarn com.aayushatharva.brotli4j.decoder.DecoderJNI$Status
40 | -dontwarn com.aayushatharva.brotli4j.decoder.DecoderJNI$Wrapper
41 | -dontwarn com.aayushatharva.brotli4j.encoder.BrotliEncoderChannel
42 | -dontwarn com.aayushatharva.brotli4j.encoder.Encoder$Mode
43 | -dontwarn com.aayushatharva.brotli4j.encoder.Encoder$Parameters
44 | -dontwarn com.github.luben.zstd.Zstd
45 | -dontwarn com.github.luben.zstd.ZstdInputStreamNoFinalizer
46 | -dontwarn com.github.luben.zstd.util.Native
47 | -dontwarn com.google.protobuf.ExtensionRegistry
48 | -dontwarn com.google.protobuf.ExtensionRegistryLite
49 | -dontwarn com.google.protobuf.MessageLite$Builder
50 | -dontwarn com.google.protobuf.MessageLite
51 | -dontwarn com.google.protobuf.MessageLiteOrBuilder
52 | -dontwarn com.google.protobuf.Parser
53 | -dontwarn com.google.protobuf.nano.CodedOutputByteBufferNano
54 | -dontwarn com.google.protobuf.nano.MessageNano
55 | -dontwarn com.jcraft.jzlib.Deflater
56 | -dontwarn com.jcraft.jzlib.Inflater
57 | -dontwarn com.jcraft.jzlib.JZlib$WrapperType
58 | -dontwarn com.jcraft.jzlib.JZlib
59 | -dontwarn com.ning.compress.BufferRecycler
60 | -dontwarn com.ning.compress.lzf.ChunkDecoder
61 | -dontwarn com.ning.compress.lzf.ChunkEncoder
62 | -dontwarn com.ning.compress.lzf.LZFChunk
63 | -dontwarn com.ning.compress.lzf.LZFEncoder
64 | -dontwarn com.ning.compress.lzf.util.ChunkDecoderFactory
65 | -dontwarn com.ning.compress.lzf.util.ChunkEncoderFactory
66 | -dontwarn com.oracle.svm.core.annotate.Alias
67 | -dontwarn com.oracle.svm.core.annotate.InjectAccessors
68 | -dontwarn com.oracle.svm.core.annotate.RecomputeFieldValue$Kind
69 | -dontwarn com.oracle.svm.core.annotate.RecomputeFieldValue
70 | -dontwarn com.oracle.svm.core.annotate.TargetClass
71 | -dontwarn com.sun.nio.file.SensitivityWatchEventModifier
72 | -dontwarn io.netty.internal.tcnative.AsyncSSLPrivateKeyMethod
73 | -dontwarn io.netty.internal.tcnative.AsyncTask
74 | -dontwarn io.netty.internal.tcnative.Buffer
75 | -dontwarn io.netty.internal.tcnative.CertificateCallback
76 | -dontwarn io.netty.internal.tcnative.CertificateCompressionAlgo
77 | -dontwarn io.netty.internal.tcnative.CertificateVerifier
78 | -dontwarn io.netty.internal.tcnative.Library
79 | -dontwarn io.netty.internal.tcnative.ResultCallback
80 | -dontwarn io.netty.internal.tcnative.SSL
81 | -dontwarn io.netty.internal.tcnative.SSLContext
82 | -dontwarn io.netty.internal.tcnative.SSLPrivateKeyMethod
83 | -dontwarn io.netty.internal.tcnative.SSLSession
84 | -dontwarn io.netty.internal.tcnative.SSLSessionCache
85 | -dontwarn io.netty.internal.tcnative.SessionTicketKey
86 | -dontwarn io.netty.internal.tcnative.SniHostNameMatcher
87 | -dontwarn java.beans.BeanInfo
88 | -dontwarn java.beans.IntrospectionException
89 | -dontwarn java.beans.Introspector
90 | -dontwarn java.beans.PropertyDescriptor
91 | -dontwarn java.lang.management.ManagementFactory
92 | -dontwarn java.lang.management.RuntimeMXBean
93 | -dontwarn javax.lang.model.element.Modifier
94 | -dontwarn lzma.sdk.ICodeProgress
95 | -dontwarn lzma.sdk.lzma.Encoder
96 | -dontwarn net.jpountz.lz4.LZ4Compressor
97 | -dontwarn net.jpountz.lz4.LZ4Exception
98 | -dontwarn net.jpountz.lz4.LZ4Factory
99 | -dontwarn net.jpountz.lz4.LZ4FastDecompressor
100 | -dontwarn net.jpountz.xxhash.XXHash32
101 | -dontwarn net.jpountz.xxhash.XXHashFactory
102 | -dontwarn org.apache.log4j.Level
103 | -dontwarn org.apache.log4j.Logger
104 | -dontwarn org.apache.log4j.Priority
105 | -dontwarn org.apache.logging.log4j.Level
106 | -dontwarn org.apache.logging.log4j.LogManager
107 | -dontwarn org.apache.logging.log4j.Logger
108 | -dontwarn org.apache.logging.log4j.message.MessageFactory
109 | -dontwarn org.apache.logging.log4j.spi.ExtendedLogger
110 | -dontwarn org.apache.logging.log4j.spi.ExtendedLoggerWrapper
111 | -dontwarn org.eclipse.jetty.npn.NextProtoNego$ClientProvider
112 | -dontwarn org.eclipse.jetty.npn.NextProtoNego$Provider
113 | -dontwarn org.eclipse.jetty.npn.NextProtoNego$ServerProvider
114 | -dontwarn org.eclipse.jetty.npn.NextProtoNego
115 | -dontwarn org.jboss.marshalling.ByteInput
116 | -dontwarn org.jboss.marshalling.ByteOutput
117 | -dontwarn org.jboss.marshalling.Marshaller
118 | -dontwarn org.jboss.marshalling.MarshallerFactory
119 | -dontwarn org.jboss.marshalling.MarshallingConfiguration
120 | -dontwarn org.jboss.marshalling.Unmarshaller
121 | -dontwarn org.osgi.annotation.bundle.Export
122 | -dontwarn reactor.blockhound.BlockHound$Builder
123 | -dontwarn reactor.blockhound.integration.BlockHoundIntegration
124 | -dontwarn sun.security.x509.AlgorithmId
125 | -dontwarn sun.security.x509.CertificateAlgorithmId
126 | -dontwarn sun.security.x509.CertificateSerialNumber
127 | -dontwarn sun.security.x509.CertificateSubjectName
128 | -dontwarn sun.security.x509.CertificateValidity
129 | -dontwarn sun.security.x509.CertificateVersion
130 | -dontwarn sun.security.x509.CertificateX509Key
131 | -dontwarn sun.security.x509.X500Name
132 | -dontwarn sun.security.x509.X509CertImpl
133 | -dontwarn sun.security.x509.X509CertInfo
134 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
9 |
12 |
15 |
18 |
21 |
24 |
27 |
30 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
43 |
44 |
47 |
48 |
49 |
50 |
61 |
66 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
86 |
87 |
94 |
97 |
98 |
99 |
100 |
101 |
102 |
107 |
112 |
117 |
118 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
136 |
137 |
142 |
145 |
146 |
147 |
148 |
--------------------------------------------------------------------------------
/app/src/main/aidl/moe/reimu/catshare/IMacAddressService.aidl:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare;
2 |
3 | interface IMacAddressService {
4 | void destroy() = 16777114; // Destroy method defined by Shizuku server
5 |
6 | void exit() = 1; // Exit method defined by user
7 |
8 | String getP2pMacAddress() = 2;
9 | String getMacAddressByName(String name) = 3;
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/app/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/AppSettings.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare
2 |
3 | import android.content.Context
4 | import android.content.SharedPreferences
5 |
6 | class AppSettings(private val context: Context) {
7 | private val prefs: SharedPreferences = context.getSharedPreferences("app", Context.MODE_PRIVATE)
8 |
9 | var deviceName: String
10 | get() = prefs.getString(
11 | "deviceName",
12 | context.getString(R.string.device_name_default_value)
13 | )!!
14 | set(value) {
15 | prefs.edit().putString("deviceName", value).apply()
16 | }
17 |
18 | var verbose: Boolean
19 | get() = prefs.getBoolean("verbose", false)
20 | set(value) {
21 | prefs.edit().putBoolean("verbose", value).apply()
22 | }
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/BleSecurity.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare
2 |
3 | import java.security.KeyFactory
4 | import java.security.KeyPairGenerator
5 | import java.security.interfaces.ECPrivateKey
6 | import java.security.interfaces.ECPublicKey
7 | import java.security.spec.X509EncodedKeySpec
8 | import java.util.Base64
9 | import javax.crypto.Cipher
10 | import javax.crypto.KeyAgreement
11 | import javax.crypto.spec.IvParameterSpec
12 | import javax.crypto.spec.SecretKeySpec
13 |
14 | object BleSecurity {
15 | private val localPrivateKey: ECPrivateKey
16 | private val localPublicKey: ECPublicKey
17 |
18 | init {
19 | val kg = KeyPairGenerator.getInstance("EC")
20 | kg.initialize(256)
21 | val kp = kg.generateKeyPair()
22 | localPublicKey = kp.public as ECPublicKey
23 | localPrivateKey = kp.private as ECPrivateKey
24 | }
25 |
26 | fun deriveSessionKey(publicKey: String): SessionCipher {
27 | val kf = KeyFactory.getInstance("EC")
28 | val otherPublicKey =
29 | kf.generatePublic(X509EncodedKeySpec(Base64.getDecoder().decode(publicKey)))
30 | val agreement = KeyAgreement.getInstance("ECDH")
31 | agreement.init(localPrivateKey)
32 | agreement.doPhase(otherPublicKey, true)
33 | val secret = agreement.generateSecret("TlsPremasterSecret")
34 | return SessionCipher(SecretKeySpec(secret.encoded, "AES"))
35 | }
36 |
37 | fun getEncodedPublicKey(): String {
38 | return Base64.getEncoder().encodeToString(localPublicKey.encoded)
39 | }
40 |
41 |
42 | class SessionCipher(private val key: SecretKeySpec) {
43 | fun decrypt(encodedData: String): String {
44 | val data = Base64.getDecoder().decode(encodedData)
45 | val cipher = Cipher.getInstance("AES/CTR/NoPadding")
46 | cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec("0102030405060708".toByteArray()))
47 | return cipher.doFinal(data).decodeToString()
48 | }
49 |
50 | fun encrypt(data: String): String {
51 | val cipher = Cipher.getInstance("AES/CTR/NoPadding")
52 | cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec("0102030405060708".toByteArray()))
53 | return Base64.getEncoder()
54 | .encodeToString(cipher.doFinal(data.toByteArray(Charsets.UTF_8)))
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/FakeTrustManager.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare
2 |
3 | import android.annotation.SuppressLint
4 | import java.security.cert.X509Certificate
5 | import javax.net.ssl.X509TrustManager
6 |
7 | @SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager")
8 | class FakeTrustManager : X509TrustManager {
9 |
10 | override fun checkClientTrusted(chain: Array, authType: String) {
11 |
12 | }
13 |
14 | override fun checkServerTrusted(chain: Array, authType: String) {
15 |
16 | }
17 |
18 | override fun getAcceptedIssuers(): Array {
19 | return emptyArray()
20 | }
21 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare
2 |
3 | import android.Manifest
4 | import android.content.BroadcastReceiver
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.content.IntentFilter
8 | import android.content.pm.PackageManager
9 | import android.net.Uri
10 | import android.os.Build
11 | import android.os.Bundle
12 | import android.util.Log
13 | import android.widget.Toast
14 | import androidx.activity.ComponentActivity
15 | import androidx.activity.compose.rememberLauncherForActivityResult
16 | import androidx.activity.compose.setContent
17 | import androidx.activity.enableEdgeToEdge
18 | import androidx.activity.result.contract.ActivityResultContract
19 | import androidx.activity.result.launch
20 | import androidx.compose.foundation.layout.Arrangement
21 | import androidx.compose.foundation.layout.Column
22 | import androidx.compose.foundation.layout.PaddingValues
23 | import androidx.compose.foundation.layout.Row
24 | import androidx.compose.foundation.layout.Spacer
25 | import androidx.compose.foundation.layout.padding
26 | import androidx.compose.foundation.layout.size
27 | import androidx.compose.foundation.lazy.LazyColumn
28 | import androidx.compose.foundation.lazy.rememberLazyListState
29 | import androidx.compose.material.icons.Icons
30 | import androidx.compose.material.icons.filled.Settings
31 | import androidx.compose.material.icons.filled.Share
32 | import androidx.compose.material3.ExperimentalMaterial3Api
33 | import androidx.compose.material3.Icon
34 | import androidx.compose.material3.IconButton
35 | import androidx.compose.material3.MaterialTheme
36 | import androidx.compose.material3.Scaffold
37 | import androidx.compose.material3.Switch
38 | import androidx.compose.material3.Text
39 | import androidx.compose.material3.TopAppBar
40 | import androidx.compose.runtime.Composable
41 | import androidx.compose.runtime.DisposableEffect
42 | import androidx.compose.runtime.getValue
43 | import androidx.compose.runtime.mutableStateOf
44 | import androidx.compose.runtime.remember
45 | import androidx.compose.runtime.setValue
46 | import androidx.compose.ui.Alignment
47 | import androidx.compose.ui.Modifier
48 | import androidx.compose.ui.graphics.vector.ImageVector
49 | import androidx.compose.ui.platform.LocalContext
50 | import androidx.compose.ui.res.stringResource
51 | import androidx.compose.ui.res.vectorResource
52 | import androidx.compose.ui.unit.dp
53 | import androidx.core.content.ContextCompat
54 | import moe.reimu.catshare.services.GattServerService
55 | import moe.reimu.catshare.ui.DefaultCard
56 | import moe.reimu.catshare.ui.theme.CatShareTheme
57 | import moe.reimu.catshare.utils.ServiceState
58 | import moe.reimu.catshare.utils.TAG
59 | import moe.reimu.catshare.utils.registerInternalBroadcastReceiver
60 | import rikka.shizuku.Shizuku
61 | import java.util.ArrayList
62 |
63 | class MainActivity : ComponentActivity() {
64 | override fun onCreate(savedInstanceState: Bundle?) {
65 | super.onCreate(savedInstanceState)
66 |
67 | checkAndRequestPermissions()
68 |
69 | enableEdgeToEdge()
70 | setContent {
71 | CatShareTheme {
72 | MainActivityContent()
73 | }
74 | }
75 | }
76 |
77 | private fun checkAndRequestPermissions() {
78 | val permissionsToRequest = mutableListOf()
79 |
80 | if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(
81 | this, Manifest.permission.NEARBY_WIFI_DEVICES
82 | ) != PackageManager.PERMISSION_GRANTED
83 | ) {
84 | permissionsToRequest.add(Manifest.permission.NEARBY_WIFI_DEVICES)
85 | }
86 |
87 | if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(
88 | this, Manifest.permission.POST_NOTIFICATIONS
89 | ) != PackageManager.PERMISSION_GRANTED
90 | ) {
91 | permissionsToRequest.add(Manifest.permission.POST_NOTIFICATIONS)
92 | }
93 |
94 | if (Build.VERSION.SDK_INT <= 32 && ContextCompat.checkSelfPermission(
95 | this, Manifest.permission.ACCESS_FINE_LOCATION
96 | ) != PackageManager.PERMISSION_GRANTED
97 | ) {
98 | permissionsToRequest.add(Manifest.permission.ACCESS_FINE_LOCATION)
99 | }
100 |
101 | if (Build.VERSION.SDK_INT >= 31) {
102 | for (perm in listOf(
103 | Manifest.permission.BLUETOOTH_ADVERTISE,
104 | Manifest.permission.BLUETOOTH_SCAN,
105 | Manifest.permission.BLUETOOTH_CONNECT
106 | )) {
107 | if (ContextCompat.checkSelfPermission(
108 | this, perm
109 | ) != PackageManager.PERMISSION_GRANTED
110 | ) {
111 | permissionsToRequest.add(perm)
112 | }
113 | }
114 | }
115 |
116 | if (permissionsToRequest.isNotEmpty()) {
117 | requestPermissions(permissionsToRequest.toTypedArray(), 0)
118 | }
119 | }
120 |
121 | override fun onRequestPermissionsResult(
122 | requestCode: Int, permissions: Array, grantResults: IntArray, deviceId: Int
123 | ) {
124 | for ((name, status) in permissions.zip(grantResults.toList())) {
125 | if (status == PackageManager.PERMISSION_GRANTED) {
126 | continue
127 | }
128 |
129 | Toast.makeText(this, "$name not granted", Toast.LENGTH_LONG).show()
130 | finish()
131 |
132 | return
133 | }
134 | }
135 | }
136 |
137 | @OptIn(ExperimentalMaterial3Api::class)
138 | @Composable
139 | fun MainActivityContent() {
140 | var checked by remember { mutableStateOf(false) }
141 | val listState = rememberLazyListState()
142 |
143 | val context = LocalContext.current
144 | DisposableEffect(context) {
145 | val receiver = object : BroadcastReceiver() {
146 | override fun onReceive(context: Context, intent: Intent) {
147 | if (intent.action == ServiceState.ACTION_UPDATE_RECEIVER_STATE) {
148 | checked = intent.getBooleanExtra("isRunning", false)
149 | }
150 | }
151 | }
152 |
153 | context.registerInternalBroadcastReceiver(
154 | receiver,
155 | IntentFilter(ServiceState.ACTION_UPDATE_RECEIVER_STATE),
156 | )
157 | context.sendBroadcast(ServiceState.getQueryIntent())
158 |
159 | onDispose {
160 | context.unregisterReceiver(receiver)
161 | }
162 | }
163 |
164 | val localMacAddressGranted = remember {
165 | context.checkSelfPermission("android.permission.LOCAL_MAC_ADDRESS") == PackageManager.PERMISSION_GRANTED
166 | }
167 |
168 | var shizukuGranted by remember {
169 | mutableStateOf(false)
170 | }
171 |
172 | var shizukuAvailable by remember {
173 | mutableStateOf(false)
174 | }
175 |
176 | DisposableEffect(Unit) {
177 | val permissionListener = Shizuku.OnRequestPermissionResultListener { _, grantResult ->
178 | Log.d(TAG, "Shizuku grant result: $grantResult")
179 | shizukuGranted = grantResult == PackageManager.PERMISSION_GRANTED
180 | }
181 |
182 | val binderRecvListener = Shizuku.OnBinderReceivedListener {
183 | shizukuAvailable = true
184 | shizukuGranted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
185 | }
186 |
187 | val binderDeadReceiver = Shizuku.OnBinderDeadListener {
188 | shizukuAvailable = false
189 | }
190 |
191 | Shizuku.addRequestPermissionResultListener(permissionListener)
192 | Shizuku.addBinderReceivedListenerSticky(binderRecvListener)
193 | Shizuku.addBinderDeadListener(binderDeadReceiver)
194 |
195 | onDispose {
196 | Shizuku.removeRequestPermissionResultListener(permissionListener)
197 | Shizuku.removeBinderReceivedListener(binderRecvListener)
198 | Shizuku.removeBinderDeadListener(binderDeadReceiver)
199 | }
200 | }
201 |
202 | val pickFilesLauncher = rememberLauncherForActivityResult(ChooseFilesContract()) { pickedUris ->
203 | if (pickedUris.isNotEmpty()) {
204 | val intent = Intent(context, ShareActivity::class.java)
205 | .putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(pickedUris))
206 | context.startActivity(intent)
207 | }
208 | }
209 |
210 | val iconMod = Modifier
211 | .size(48.dp)
212 | .padding(end = 16.dp)
213 |
214 | Scaffold(topBar = {
215 | TopAppBar(title = { Text(text = stringResource(R.string.app_name)) }, actions = {
216 | IconButton(onClick = {
217 | context.startActivity(Intent(context, SettingsActivity::class.java))
218 | }) {
219 | Icon(
220 | imageVector = Icons.Filled.Settings,
221 | contentDescription = stringResource(R.string.title_activity_settings)
222 | )
223 | }
224 | })
225 | }) { innerPadding ->
226 | LazyColumn(
227 | modifier = Modifier.padding(innerPadding),
228 | state = listState,
229 | verticalArrangement = Arrangement.spacedBy(8.dp),
230 | contentPadding = PaddingValues(horizontal = 16.dp),
231 | ) {
232 | item {
233 | DefaultCard(onClick = {
234 | pickFilesLauncher.launch()
235 | }) {
236 | Row(
237 | modifier = Modifier.padding(16.dp),
238 | verticalAlignment = Alignment.CenterVertically
239 | ) {
240 | Icon(
241 | imageVector = Icons.Filled.Share,
242 | contentDescription = null,
243 | modifier = iconMod,
244 | )
245 | Column {
246 | Text(
247 | text = stringResource(R.string.send),
248 | style = MaterialTheme.typography.titleMedium,
249 | )
250 | Text(
251 | text = stringResource(R.string.send_desc),
252 | )
253 | }
254 | }
255 | }
256 |
257 | }
258 | item {
259 | DefaultCard {
260 | Row(
261 | modifier = Modifier.padding(16.dp),
262 | verticalAlignment = Alignment.CenterVertically
263 | ) {
264 | Icon(
265 | imageVector = ImageVector.vectorResource(R.drawable.ic_bluetooth_searching),
266 | contentDescription = null,
267 | modifier = iconMod,
268 | )
269 | Column {
270 | Text(
271 | text = stringResource(R.string.discoverable),
272 | style = MaterialTheme.typography.titleMedium,
273 | )
274 | Text(
275 | text = stringResource(R.string.discoverable_desc),
276 | )
277 | }
278 | Spacer(modifier = Modifier.weight(1.0f))
279 | Switch(checked = checked, onCheckedChange = {
280 | if (it) {
281 | GattServerService.start(context)
282 | } else {
283 | GattServerService.stop(context)
284 | }
285 | })
286 | }
287 | }
288 | }
289 |
290 | if (!localMacAddressGranted) {
291 | item {
292 | DefaultCard(onClick = {
293 | if (!shizukuGranted) {
294 | try {
295 | Shizuku.requestPermission(0)
296 | } catch (e: Throwable) {
297 | e.printStackTrace()
298 | }
299 | }
300 | }) {
301 | Row(
302 | modifier = Modifier.padding(16.dp),
303 | verticalAlignment = Alignment.CenterVertically
304 | ) {
305 | Icon(
306 | imageVector = if (shizukuAvailable && shizukuGranted) {
307 | ImageVector.vectorResource(R.drawable.ic_done)
308 | } else {
309 | ImageVector.vectorResource(R.drawable.ic_close)
310 | },
311 | contentDescription = null,
312 | modifier = iconMod,
313 | )
314 | Column {
315 | Text(
316 | text = stringResource(
317 | if (shizukuAvailable) {
318 | if (shizukuGranted) {
319 | R.string.shizuku_available
320 | } else {
321 | R.string.shizuku_not_granted
322 | }
323 | } else {
324 | R.string.shizuku_unavailable
325 | }
326 | ),
327 | style = MaterialTheme.typography.titleMedium,
328 | )
329 | Text(
330 | text = stringResource(R.string.shizuku_desc),
331 | )
332 | }
333 | }
334 | }
335 | }
336 | }
337 | }
338 | }
339 | }
340 |
341 |
342 | class ChooseFilesContract : ActivityResultContract>() {
343 | override fun createIntent(context: Context, input: Void?): Intent {
344 | val cf = Intent(Intent.ACTION_GET_CONTENT)
345 | .setType("*/*")
346 | .addCategory(Intent.CATEGORY_OPENABLE)
347 | .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
348 | return Intent.createChooser(cf, context.getString(R.string.choose_files))
349 | }
350 |
351 | override fun getSynchronousResult(
352 | context: Context,
353 | input: Void?
354 | ): SynchronousResult>? =
355 | null
356 |
357 | override fun parseResult(resultCode: Int, intent: Intent?): List {
358 | if (intent == null) {
359 | return emptyList()
360 | }
361 |
362 | val ret = mutableListOf()
363 |
364 | val clipData = intent.clipData
365 | if (clipData != null) {
366 | for (i in 0..
91 | val listState = rememberLazyListState()
92 |
93 | LazyColumn(
94 | modifier = Modifier.padding(innerPadding),
95 | state = listState,
96 | verticalArrangement = Arrangement.spacedBy(8.dp),
97 | contentPadding = PaddingValues(horizontal = 16.dp),
98 | ) {
99 | item {
100 | DefaultCard {
101 | Column(modifier = Modifier.padding(16.dp)) {
102 | OutlinedTextField(
103 | value = deviceNameValue,
104 | onValueChange = { deviceNameValue = it },
105 | label = { Text(stringResource(R.string.device_name)) },
106 | modifier = Modifier.fillMaxWidth()
107 | )
108 | }
109 | }
110 | }
111 | item {
112 | DefaultCard {
113 | Row(
114 | modifier = Modifier.padding(16.dp),
115 | verticalAlignment = Alignment.CenterVertically
116 | ) {
117 | Text(
118 | text = stringResource(R.string.verbose_name),
119 | style = MaterialTheme.typography.titleMedium,
120 | )
121 | Spacer(modifier = Modifier.weight(1.0f))
122 | Switch(checked = verboseValue, onCheckedChange = {
123 | verboseValue = it
124 | })
125 | }
126 | }
127 | }
128 | item {
129 | DefaultCard(onClick = {
130 | Thread {
131 | try {
132 | val context = MyApplication.getInstance()
133 | val logDir = File(context.cacheDir, "logs")
134 | logDir.mkdirs()
135 | val logFile = File(logDir, "logcat.txt")
136 |
137 | logFile.outputStream().use {
138 | val proc = Runtime.getRuntime().exec("logcat -d")
139 | try {
140 | proc.inputStream.copyTo(it)
141 | } finally {
142 | proc.destroy()
143 | }
144 | }
145 | val uri = FileProvider.getUriForFile(
146 | activity,
147 | "${BuildConfig.APPLICATION_ID}.fileProvider",
148 | logFile
149 | )
150 | val intent = Intent(Intent.ACTION_SEND)
151 | .putExtra(Intent.EXTRA_STREAM, uri)
152 | .setType("text/plain")
153 | .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
154 | activity.startActivity(intent)
155 | } catch (e: Exception) {
156 | Log.e("LogcatCapture", "Failed to save logs", e)
157 | Toast.makeText(
158 | activity,
159 | activity.getString(R.string.log_capture_failed),
160 | Toast.LENGTH_LONG
161 | ).show()
162 | }
163 | }.start()
164 | }) {
165 | Row(
166 | modifier = Modifier.padding(16.dp),
167 | verticalAlignment = Alignment.CenterVertically
168 | ) {
169 | Column {
170 | Text(
171 | text = stringResource(R.string.capture_logs),
172 | style = MaterialTheme.typography.titleMedium,
173 | )
174 | Text(
175 | text = stringResource(R.string.capture_logs_desc),
176 | )
177 | }
178 | }
179 | }
180 | }
181 | }
182 | }
183 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/ShareActivity.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare
2 |
3 | import android.annotation.SuppressLint
4 | import android.bluetooth.BluetoothManager
5 | import android.bluetooth.le.BluetoothLeScanner
6 | import android.bluetooth.le.ScanCallback
7 | import android.bluetooth.le.ScanFilter
8 | import android.bluetooth.le.ScanResult
9 | import android.bluetooth.le.ScanSettings
10 | import android.content.ComponentName
11 | import android.content.Context
12 | import android.content.Intent
13 | import android.content.ServiceConnection
14 | import android.net.Uri
15 | import android.net.wifi.WifiManager
16 | import android.os.Bundle
17 | import android.os.IBinder
18 | import android.os.ParcelUuid
19 | import android.provider.MediaStore
20 | import android.util.Log
21 | import android.widget.Toast
22 | import androidx.activity.ComponentActivity
23 | import androidx.activity.compose.setContent
24 | import androidx.activity.enableEdgeToEdge
25 | import androidx.compose.foundation.layout.Arrangement
26 | import androidx.compose.foundation.layout.Column
27 | import androidx.compose.foundation.layout.PaddingValues
28 | import androidx.compose.foundation.layout.Row
29 | import androidx.compose.foundation.layout.padding
30 | import androidx.compose.foundation.layout.size
31 | import androidx.compose.foundation.lazy.LazyColumn
32 | import androidx.compose.foundation.lazy.items
33 | import androidx.compose.foundation.lazy.rememberLazyListState
34 | import androidx.compose.material.icons.Icons
35 | import androidx.compose.material.icons.filled.AccountCircle
36 | import androidx.compose.material3.ExperimentalMaterial3Api
37 | import androidx.compose.material3.Icon
38 | import androidx.compose.material3.MaterialTheme
39 | import androidx.compose.material3.Scaffold
40 | import androidx.compose.material3.Text
41 | import androidx.compose.material3.TopAppBar
42 | import androidx.compose.runtime.Composable
43 | import androidx.compose.runtime.getValue
44 | import androidx.compose.runtime.mutableStateOf
45 | import androidx.compose.runtime.remember
46 | import androidx.compose.runtime.setValue
47 | import androidx.compose.ui.Alignment
48 | import androidx.compose.ui.Modifier
49 | import androidx.compose.ui.platform.LocalContext
50 | import androidx.compose.ui.res.stringResource
51 | import androidx.compose.ui.unit.dp
52 | import androidx.lifecycle.compose.LifecycleResumeEffect
53 | import androidx.lifecycle.compose.LifecycleStartEffect
54 | import moe.reimu.catshare.models.DiscoveredDevice
55 | import moe.reimu.catshare.models.FileInfo
56 | import moe.reimu.catshare.models.TaskInfo
57 | import moe.reimu.catshare.services.P2pSenderService
58 | import moe.reimu.catshare.ui.DefaultCard
59 | import moe.reimu.catshare.ui.theme.CatShareTheme
60 | import moe.reimu.catshare.utils.BleUtils
61 | import moe.reimu.catshare.utils.DeviceUtils
62 | import moe.reimu.catshare.utils.NotificationUtils
63 | import moe.reimu.catshare.utils.ShizukuUtils
64 | import moe.reimu.catshare.utils.TAG
65 | import java.nio.ByteBuffer
66 | import kotlin.random.Random
67 |
68 | class ShareActivity : ComponentActivity() {
69 | private lateinit var bluetoothManager: BluetoothManager
70 |
71 | override fun onCreate(savedInstanceState: Bundle?) {
72 | super.onCreate(savedInstanceState)
73 |
74 | bluetoothManager = getSystemService(BluetoothManager::class.java)
75 | val adapter = bluetoothManager.adapter
76 | if (adapter == null || !adapter.isEnabled) {
77 | NotificationUtils.showBluetoothToast(this)
78 | finish()
79 | return
80 | }
81 |
82 | val wifiManager = getSystemService(WifiManager::class.java)
83 | if (!wifiManager.isWifiEnabled) {
84 | NotificationUtils.showWifiToast(this)
85 | finish()
86 | return
87 | }
88 |
89 | val fileInfos = try {
90 | if (intent.action == Intent.ACTION_SEND) {
91 | val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM)
92 | if (uri != null) {
93 | listOf(uri).mapNotNull { extractFileInfo(it) }
94 | } else {
95 | val text = intent.getStringExtra(Intent.EXTRA_TEXT)
96 | listOf(
97 | FileInfo(
98 | Uri.EMPTY, "", "", 0, text
99 | )
100 | )
101 | }
102 | } else {
103 | val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
104 | uris?.mapNotNull { extractFileInfo(it) } ?: emptyList()
105 | }
106 | } catch (e: Throwable) {
107 | Toast.makeText(this, R.string.no_file_shared, Toast.LENGTH_SHORT).show()
108 | finish()
109 | return
110 | }
111 |
112 | if (fileInfos.isEmpty()) {
113 | Toast.makeText(this, R.string.no_file_shared, Toast.LENGTH_SHORT).show()
114 | finish()
115 | return
116 | }
117 |
118 | Log.i(TAG, "Shared ${fileInfos.size} files")
119 |
120 | ShizukuUtils.bindService()
121 |
122 | enableEdgeToEdge()
123 | setContent {
124 | CatShareTheme {
125 | ShareActivityContent(fileInfos)
126 | }
127 | }
128 | }
129 |
130 | private fun extractFileInfo(uri: Uri): FileInfo? {
131 | val cr = contentResolver
132 | val proj = arrayOf(
133 | MediaStore.MediaColumns.DISPLAY_NAME,
134 | MediaStore.MediaColumns.MIME_TYPE,
135 | MediaStore.MediaColumns.SIZE
136 | )
137 | return cr.query(uri, proj, null, null)?.use {
138 | if (it.moveToFirst()) {
139 | val mimeIndex = it.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)
140 | FileInfo(
141 | uri,
142 | it.getString(it.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)),
143 | if (mimeIndex < 0) {
144 | "application/octet-stream"
145 | } else {
146 | it.getString(mimeIndex)
147 | },
148 | it.getInt(it.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)),
149 | null
150 | )
151 | } else {
152 | null
153 | }
154 | }
155 | }
156 | }
157 |
158 | @OptIn(ExperimentalMaterial3Api::class)
159 | @Composable
160 | fun ShareActivityContent(files: List) {
161 | val context = LocalContext.current
162 | val discoveredDevices = deviceScanner()
163 |
164 | var senderService by remember { mutableStateOf(null) }
165 |
166 | LifecycleStartEffect(context) {
167 | var isBound = false
168 |
169 | val connection = object : ServiceConnection {
170 | override fun onServiceConnected(name: ComponentName, service: IBinder) {
171 | val binder = service as P2pSenderService.LocalBinder
172 | isBound = true
173 | senderService = binder.getService()
174 | }
175 |
176 | override fun onServiceDisconnected(name: ComponentName) {
177 | isBound = false
178 | senderService = null
179 | }
180 | }
181 |
182 | context.bindService(
183 | Intent(context, P2pSenderService::class.java),
184 | connection,
185 | Context.BIND_AUTO_CREATE
186 | )
187 |
188 | onStopOrDispose {
189 | if (isBound) {
190 | context.unbindService(connection)
191 | isBound = false
192 | senderService = null
193 | }
194 | }
195 | }
196 |
197 | val listState = rememberLazyListState()
198 | val iconMod = Modifier
199 | .size(48.dp)
200 | .padding(end = 16.dp)
201 |
202 | Scaffold(
203 | topBar = {
204 | TopAppBar(title = { Text(text = stringResource(R.string.choose_recipient)) })
205 | }
206 | ) { innerPadding ->
207 | LazyColumn(
208 | modifier = Modifier.padding(innerPadding),
209 | state = listState,
210 | verticalArrangement = Arrangement.spacedBy(8.dp),
211 | contentPadding = PaddingValues(horizontal = 16.dp),
212 | ) {
213 | if (discoveredDevices.isEmpty()) {
214 | item {
215 | Text(stringResource(R.string.scanning_desc))
216 | }
217 | } else {
218 | items(discoveredDevices, key = { it.id }) {
219 | DefaultCard(onClick = {
220 | val task = TaskInfo(
221 | id = Random.nextInt(),
222 | device = it,
223 | files = files
224 | )
225 | P2pSenderService.startTaskChecked(context, task)
226 | }) {
227 | Row(
228 | modifier = Modifier.padding(16.dp),
229 | verticalAlignment = Alignment.CenterVertically
230 | ) {
231 | Icon(
232 | imageVector = Icons.Filled.AccountCircle,
233 | contentDescription = null,
234 | modifier = iconMod
235 | )
236 | Column {
237 | Text(
238 | text = if (BuildConfig.DEBUG) {
239 | "${it.name} (${it.id}, ${it.device.address})"
240 | } else {
241 | it.name
242 | },
243 | style = MaterialTheme.typography.titleMedium,
244 | )
245 | Text(
246 | text = it.brand ?: stringResource(R.string.unknown)
247 | )
248 | }
249 | }
250 | }
251 | }
252 | }
253 | }
254 | }
255 | }
256 |
257 | @SuppressLint("MissingPermission")
258 | @Composable
259 | fun deviceScanner(): List {
260 | val context = LocalContext.current
261 | var discoveredDevices by remember { mutableStateOf(emptyList()) }
262 |
263 | LifecycleResumeEffect(context) {
264 | val manager = context.getSystemService(BluetoothManager::class.java)
265 | val adapter = manager.adapter
266 | val devicesLock = Object()
267 |
268 | val callback = object : ScanCallback() {
269 | override fun onScanFailed(errorCode: Int) {
270 | println()
271 | }
272 |
273 | override fun onScanResult(callbackType: Int, result: ScanResult) {
274 | val record = result.scanRecord ?: return
275 | var supports5Ghz = false
276 | var deviceName: String? = null
277 | var brandId: Byte? = null
278 | var senderId: String? = null
279 |
280 | for ((uuid, data) in record.serviceData.entries) {
281 | when (data.size) {
282 | 6 -> {
283 | // UUID contains brand and 5GHz flag
284 | val buf = ByteBuffer.allocate(16)
285 | buf.putLong(uuid.uuid.mostSignificantBits)
286 | buf.putLong(uuid.uuid.leastSignificantBits)
287 | val arr = buf.array()
288 | supports5Ghz = arr[2].toInt() == 1
289 | brandId = arr[3]
290 | }
291 |
292 | 27 -> {
293 | // Data contains device name and ID
294 | val nameBuf = mutableListOf()
295 | for (i in 10..25) {
296 | if (data[i].toInt() != 0) {
297 | nameBuf.add(data[i])
298 | } else {
299 | break
300 | }
301 | }
302 |
303 | val senderIdRaw = data[8].toInt().shl(8).or(data[9].toInt())
304 | senderId = String.format("%04x", senderIdRaw)
305 |
306 | var name = nameBuf.toByteArray().decodeToString()
307 | if (name.last() == '\t') {
308 | name = name.removeSuffix("\t") + "..."
309 | }
310 | deviceName = name
311 | }
312 | }
313 | }
314 |
315 | if (deviceName == null || senderId == null) {
316 | return
317 | }
318 |
319 | val brand = brandId?.let {
320 | DeviceUtils.deviceNameById(it)
321 | }
322 |
323 | val newDevice = DiscoveredDevice(
324 | result.device, senderId, deviceName, brand, supports5Ghz
325 | )
326 | var replaced = false
327 | synchronized(devicesLock) {
328 | val newList = discoveredDevices.map {
329 | if (it.id == senderId) {
330 | replaced = true
331 | newDevice
332 | } else {
333 | it
334 | }
335 | }.toMutableList()
336 | if (!replaced) {
337 | newList.add(newDevice)
338 | }
339 | discoveredDevices = newList
340 | }
341 | }
342 | }
343 |
344 | var startedScanner: BluetoothLeScanner? = null
345 |
346 | if (adapter != null) {
347 | val scanner = adapter.bluetoothLeScanner
348 | val filters = listOf(
349 | ScanFilter.Builder().setServiceUuid(ParcelUuid(BleUtils.ADV_SERVICE_UUID)).build()
350 | )
351 | val settings =
352 | ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
353 |
354 | try {
355 | scanner.startScan(filters, settings, callback)
356 | startedScanner = scanner
357 | Log.d(TAG, "Started scanning")
358 | } catch (e: SecurityException) {
359 | Log.e(TAG, "Failed to start scan", e)
360 | }
361 | }
362 |
363 | onPauseOrDispose {
364 | try {
365 | startedScanner?.stopScan(callback)
366 | Log.d(TAG, "Stopped scanning")
367 | } catch (e: Exception) {
368 | Log.e(TAG, "Failed to stop scan", e)
369 | }
370 | }
371 | }
372 |
373 | return discoveredDevices
374 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/StartReceiverActivity.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.os.Bundle
6 | import android.widget.Toast
7 | import androidx.appcompat.app.AppCompatActivity
8 | import moe.reimu.catshare.services.GattServerService
9 |
10 | class StartReceiverActivity : AppCompatActivity() {
11 | override fun onCreate(savedInstanceState: Bundle?) {
12 | super.onCreate(savedInstanceState)
13 |
14 | if (intent.getBooleanExtra("shouldStop", false)) {
15 | stopService(GattServerService.getIntent(this))
16 | Toast.makeText(this, R.string.receiver_stopped, Toast.LENGTH_SHORT).show()
17 | } else {
18 | startService(GattServerService.getIntent(this))
19 | Toast.makeText(this, R.string.receiver_started, Toast.LENGTH_SHORT).show()
20 | }
21 |
22 | finish()
23 | }
24 |
25 | companion object {
26 | fun getIntent(context: Context, shouldStop: Boolean): Intent {
27 | return Intent(context, StartReceiverActivity::class.java).apply {
28 | putExtra("shouldStop", shouldStop)
29 | }
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/exceptions/CancelledByUserException.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.exceptions
2 |
3 | import io.ktor.utils.io.CancellationException
4 |
5 | class CancelledByUserException(val isRemote: Boolean) :
6 | CancellationException("Cancelled by user")
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/exceptions/ExceptionWithMessage.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.exceptions
2 |
3 | import android.content.Context
4 | import androidx.annotation.StringRes
5 |
6 | class ExceptionWithMessage : Exception {
7 | private val messageContent: String?
8 | private val messageId: Int?
9 |
10 | constructor(message: String, cause: Throwable, @StringRes userFacingMessage: Int) : super(
11 | message,
12 | cause
13 | ) {
14 | messageId = userFacingMessage
15 | messageContent = null
16 | }
17 |
18 | constructor(message: String, cause: Throwable, userFacingMessage: String) : super(
19 | message,
20 | cause
21 | ) {
22 | messageId = null
23 | messageContent = userFacingMessage
24 | }
25 |
26 | fun getMessage(context: Context): String {
27 | if (messageId != null) {
28 | return context.getString(messageId)
29 | }
30 | if (messageContent != null) {
31 | return messageContent
32 | }
33 | return ""
34 | }
35 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/models/DeviceInfo.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.models
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class DeviceInfo(val state: Int, val key: String?, val mac: String, val catShare: Int? = null)
7 |
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/models/DiscoveredDevice.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.models
2 |
3 | import android.bluetooth.BluetoothDevice
4 | import android.os.Parcelable
5 | import kotlinx.parcelize.Parcelize
6 |
7 | @Parcelize
8 | data class DiscoveredDevice(
9 | val device: BluetoothDevice,
10 | val id: String,
11 | val name: String,
12 | val brand: String?,
13 | val supports5Ghz: Boolean
14 | ) : Parcelable
15 |
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/models/FileInfo.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.models
2 |
3 | import android.net.Uri
4 | import android.os.Parcelable
5 | import kotlinx.parcelize.Parcelize
6 |
7 | @Parcelize
8 | data class FileInfo(
9 | val uri: Uri,
10 | val name: String,
11 | val mimeType: String,
12 | val size: Int,
13 | val textContent: String?,
14 | ) : Parcelable
15 |
16 |
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/models/P2pInfo.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.models
2 |
3 | import android.os.Parcelable
4 | import kotlinx.parcelize.Parcelize
5 | import kotlinx.serialization.Serializable
6 |
7 | @Serializable
8 | @Parcelize
9 | data class P2pInfo(
10 | val id: String?,
11 | val ssid: String,
12 | val psk: String,
13 | val mac: String,
14 | val port: Int,
15 | val key: String? = null,
16 | val catShare: Int? = null,
17 | ) : Parcelable
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/models/ReceivedFile.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.models
2 |
3 | import android.net.Uri
4 |
5 | data class ReceivedFile(
6 | val name: String,
7 | val uri: Uri,
8 | val mimeType: String
9 | )
10 |
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/models/TaskInfo.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.models
2 |
3 | import android.os.Parcelable
4 | import kotlinx.parcelize.Parcelize
5 |
6 | @Parcelize
7 | data class TaskInfo(val id: Int, val device: DiscoveredDevice, val files: List) : Parcelable
8 |
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/models/WebSocketMessage.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.models
2 |
3 | import org.json.JSONObject
4 | import java.util.regex.Pattern
5 |
6 | data class WebSocketMessage(
7 | val type: String, val id: Int, val name: String, val payload: JSONObject?
8 | ) {
9 | fun toText(newId: Int? = null): String {
10 | val sb = StringBuilder()
11 | sb.append(type)
12 | sb.append(':')
13 | if (newId != null) {
14 | sb.append(newId)
15 | } else {
16 | sb.append(id)
17 | }
18 | sb.append(':')
19 | sb.append(name)
20 | if (payload != null) {
21 | sb.append('?')
22 | sb.append(payload)
23 | }
24 | return sb.toString()
25 | }
26 |
27 | companion object {
28 | private val REQ_PATTERN = Pattern.compile("^(\\w+):(\\d+):(\\w+)(\\?(.*))?$")
29 |
30 |
31 | fun fromText(text: String): WebSocketMessage? {
32 | val matcher = REQ_PATTERN.matcher(text)
33 | if (!matcher.matches()) {
34 | return null
35 | }
36 |
37 | val jsonText = matcher.group(5)
38 | val jsonObj = if (jsonText != null && jsonText.isNotEmpty()) {
39 | JSONObject(jsonText)
40 | } else null
41 |
42 | return WebSocketMessage(
43 | type = matcher.group(1) ?: return null,
44 | id = (matcher.group(2) ?: return null).toInt(),
45 | name = matcher.group(3) ?: return null,
46 | payload = jsonObj
47 | )
48 | }
49 |
50 | fun makeStatus(id: Int, taskId: String, type: Int, reason: String) = WebSocketMessage(
51 | "action",
52 | id,
53 | "status",
54 | JSONObject().put("taskId", taskId).put("type", type).put("reason", reason)
55 | .put("id", taskId)
56 | )
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/services/BaseP2pService.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.services
2 |
3 | import android.app.Service
4 | import android.content.BroadcastReceiver
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.content.IntentFilter
8 | import android.net.wifi.p2p.WifiP2pManager
9 | import android.util.Log
10 | import moe.reimu.catshare.utils.getReceiverFlags
11 |
12 |
13 | abstract class BaseP2pService : Service() {
14 | private val p2pReceiver = object : BroadcastReceiver() {
15 | override fun onReceive(context: Context, intent: Intent) {
16 | Log.d("BaseP2pService", "Action: ${intent.action}")
17 | onP2pBroadcast(intent)
18 | }
19 | }
20 | private var p2pReceiverRegistered = false
21 |
22 | private val intentFilter = IntentFilter().apply {
23 | addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
24 | addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
25 | addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
26 | addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION)
27 | }
28 |
29 | protected lateinit var p2pManager: WifiP2pManager
30 | protected lateinit var p2pChannel: WifiP2pManager.Channel
31 |
32 | protected abstract fun onP2pBroadcast(intent: Intent)
33 |
34 | override fun onCreate() {
35 | super.onCreate()
36 |
37 | registerReceiver(p2pReceiver, intentFilter, getReceiverFlags())
38 | p2pReceiverRegistered = true
39 |
40 | p2pManager = getSystemService(WifiP2pManager::class.java)
41 | p2pChannel = p2pManager.initialize(this, mainLooper, null)
42 | }
43 |
44 | override fun onDestroy() {
45 | super.onDestroy()
46 | if (p2pReceiverRegistered) {
47 | unregisterReceiver(p2pReceiver)
48 | }
49 | }
50 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/services/GattServerService.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.services
2 |
3 |
4 | import android.annotation.SuppressLint
5 | import android.app.ForegroundServiceStartNotAllowedException
6 | import android.app.Notification
7 | import android.app.PendingIntent
8 | import android.app.Service
9 | import android.bluetooth.BluetoothDevice
10 | import android.bluetooth.BluetoothGattCharacteristic
11 | import android.bluetooth.BluetoothGattServer
12 | import android.bluetooth.BluetoothGattServerCallback
13 | import android.bluetooth.BluetoothGattService
14 | import android.bluetooth.BluetoothManager
15 | import android.bluetooth.le.AdvertiseData
16 | import android.bluetooth.le.AdvertisingSet
17 | import android.bluetooth.le.AdvertisingSetCallback
18 | import android.bluetooth.le.AdvertisingSetParameters
19 | import android.bluetooth.le.BluetoothLeAdvertiser
20 | import android.content.BroadcastReceiver
21 | import android.content.Context
22 | import android.content.Intent
23 | import android.content.IntentFilter
24 | import android.content.pm.ServiceInfo
25 | import android.os.Build
26 | import android.os.IBinder
27 | import android.os.ParcelUuid
28 | import android.util.Log
29 | import androidx.core.app.NotificationCompat
30 | import kotlinx.serialization.encodeToString
31 | import kotlinx.serialization.json.Json
32 | import moe.reimu.catshare.AppSettings
33 | import moe.reimu.catshare.BleSecurity
34 | import moe.reimu.catshare.BuildConfig
35 | import moe.reimu.catshare.R
36 | import moe.reimu.catshare.models.DeviceInfo
37 | import moe.reimu.catshare.models.P2pInfo
38 | import moe.reimu.catshare.utils.BleUtils
39 | import moe.reimu.catshare.utils.JsonWithUnknownKeys
40 | import moe.reimu.catshare.utils.NotificationUtils
41 | import moe.reimu.catshare.utils.ServiceState
42 | import moe.reimu.catshare.utils.ShizukuUtils
43 | import moe.reimu.catshare.utils.TAG
44 | import moe.reimu.catshare.utils.checkBluetoothPermissions
45 | import moe.reimu.catshare.utils.registerInternalBroadcastReceiver
46 | import java.util.Arrays
47 | import java.util.concurrent.ConcurrentHashMap
48 | import kotlin.math.max
49 | import kotlin.math.min
50 |
51 | class GattServerService : Service() {
52 | private lateinit var btManager: BluetoothManager
53 | private var btAdvertiser: BluetoothLeAdvertiser? = null
54 |
55 | private var advertisingSet: AdvertisingSet? = null
56 |
57 | private val localDeviceInfoLock = Object()
58 | private var localDeviceInfo = DeviceInfo(
59 | 0, BleSecurity.getEncodedPublicKey(), "02:00:00:00:00:00", BuildConfig.VERSION_CODE
60 | )
61 | private var localDeviceStatusBytes = Json.encodeToString(localDeviceInfo).toByteArray()
62 |
63 | private val internalReceiver = object : BroadcastReceiver() {
64 | override fun onReceive(context: Context, intent: Intent) {
65 | when (intent.action) {
66 | ServiceState.ACTION_QUERY_RECEIVER_STATE -> {
67 | context.sendBroadcast(ServiceState.getUpdateIntent(true))
68 | }
69 |
70 | ServiceState.ACTION_STOP_SERVICE -> {
71 | Log.i(GattServerService.TAG, "Received ACTION_STOP_SERVICE")
72 | stopSelf()
73 | }
74 | }
75 | }
76 | }
77 | private var internalReceiverRegistered = false
78 |
79 | private val advSetCallback = object : AdvertisingSetCallback() {
80 | override fun onAdvertisingSetStarted(
81 | advertisingSet: AdvertisingSet?, txPower: Int, status: Int
82 | ) {
83 | if (status == ADVERTISE_SUCCESS) {
84 | this@GattServerService.advertisingSet = advertisingSet
85 | } else {
86 | Log.e(TAG, "Advertising failed: $status")
87 | }
88 | }
89 | }
90 |
91 | private var gattServer: BluetoothGattServer? = null
92 |
93 | @SuppressLint("MissingPermission")
94 | private val gattServerCallback = object : BluetoothGattServerCallback() {
95 | private val writeRequests =
96 | ConcurrentHashMap, Pair>()
97 |
98 | override fun onCharacteristicReadRequest(
99 | device: BluetoothDevice,
100 | requestId: Int,
101 | offset: Int,
102 | characteristic: BluetoothGattCharacteristic
103 | ) {
104 | if (characteristic.uuid != BleUtils.CHAR_STATUS_UUID) {
105 | gattServer?.sendResponse(device, requestId, 257, 0, null)
106 | return
107 | }
108 |
109 | val data = synchronized(localDeviceInfoLock) {
110 | if (offset < localDeviceStatusBytes.size) {
111 | localDeviceStatusBytes.copyOfRange(offset, localDeviceStatusBytes.size)
112 | } else {
113 | null
114 | }
115 | }
116 |
117 | gattServer?.sendResponse(device, requestId, 0, 0, data)
118 | }
119 |
120 | override fun onCharacteristicWriteRequest(
121 | device: BluetoothDevice,
122 | requestId: Int,
123 | characteristic: BluetoothGattCharacteristic,
124 | preparedWrite: Boolean,
125 | responseNeeded: Boolean,
126 | offset: Int,
127 | value: ByteArray
128 | ) {
129 | if (characteristic.uuid != BleUtils.CHAR_P2P_UUID) {
130 | if (responseNeeded) {
131 | gattServer?.sendResponse(device, requestId, 257, 0, null)
132 | }
133 | return
134 | }
135 |
136 | val key = Pair(device, requestId)
137 |
138 | val writeReq = writeRequests.getOrPut(key) {
139 | Pair(ByteArray(1024), 0)
140 | }
141 |
142 | System.arraycopy(value, 0, writeReq.first, offset, value.size)
143 | val newLength = max(writeReq.second, offset + value.size)
144 |
145 | val data = if (preparedWrite) {
146 | writeRequests[key] = writeReq.copy(second = newLength)
147 | if (responseNeeded) {
148 | gattServer?.sendResponse(device, requestId, 0, 0, null)
149 | }
150 | return
151 | } else {
152 | writeRequests.remove(key)
153 | writeReq.first.copyOfRange(0, newLength)
154 | }
155 |
156 | if (responseNeeded) {
157 | gattServer?.sendResponse(device, requestId, 0, 0, null)
158 | }
159 |
160 | var p2pInfo: P2pInfo = JsonWithUnknownKeys.decodeFromString(data.decodeToString())
161 | val ecKey = p2pInfo.key
162 | if (ecKey != null) {
163 | val cipher = BleSecurity.deriveSessionKey(ecKey)
164 | p2pInfo = P2pInfo(
165 | id = BleUtils.getSenderId(),
166 | ssid = cipher.decrypt(p2pInfo.ssid),
167 | psk = cipher.decrypt(p2pInfo.psk),
168 | mac = cipher.decrypt(p2pInfo.mac),
169 | port = p2pInfo.port,
170 | key = null,
171 | catShare = BuildConfig.VERSION_CODE,
172 | )
173 | }
174 | startService(P2pReceiverService.getIntent(this@GattServerService, p2pInfo))
175 | }
176 | }
177 |
178 | override fun onCreate() {
179 | super.onCreate()
180 |
181 | if (!checkBluetoothPermissions()) {
182 | stopSelf()
183 | return
184 | }
185 |
186 | try {
187 | btManager = getSystemService(BluetoothManager::class.java)
188 | val btAdapter = btManager.adapter
189 | if (btAdapter == null || !btAdapter.isEnabled) {
190 | throw IllegalStateException("Bluetooth not enabled")
191 | }
192 | btAdvertiser = btAdapter.bluetoothLeAdvertiser
193 | } catch (e: Exception) {
194 | Log.e(TAG, "Failed to initialize BT", e)
195 | NotificationUtils.showBluetoothToast(this)
196 | stopSelf()
197 | return
198 | }
199 |
200 | try {
201 | startForeground(
202 | NotificationUtils.GATT_SERVER_FG_ID,
203 | createNotification(),
204 | ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
205 | )
206 | } catch (e: Exception) {
207 | if (Build.VERSION.SDK_INT >= 31 && e is ForegroundServiceStartNotAllowedException) {
208 | Log.e(TAG, "Service startup not allowed", e)
209 | } else {
210 | Log.e(TAG, "Service startup failed", e)
211 | }
212 | stopSelf()
213 | return
214 | }
215 |
216 | ShizukuUtils.getMacAddress(this, "p2p0") {
217 | if (it != null) {
218 | updateMacAddress(it)
219 | }
220 | }
221 |
222 | startAdv()
223 |
224 | registerInternalBroadcastReceiver(internalReceiver, IntentFilter().apply {
225 | addAction(ServiceState.ACTION_QUERY_RECEIVER_STATE)
226 | addAction(ServiceState.ACTION_STOP_SERVICE)
227 | })
228 | internalReceiverRegistered = true
229 | sendBroadcast(ServiceState.getUpdateIntent(true))
230 | }
231 |
232 | private fun createNotification(): Notification {
233 | val pi = PendingIntent.getBroadcast(
234 | this,
235 | 0,
236 | ServiceState.getStopIntent(),
237 | PendingIntent.FLAG_IMMUTABLE
238 | )
239 |
240 | return NotificationCompat.Builder(this, NotificationUtils.RECEIVER_FG_CHAN_ID)
241 | .setSmallIcon(R.drawable.ic_bluetooth_searching)
242 | .setContentTitle(getString(R.string.noti_receiver_title))
243 | .setContentText(getString(R.string.discoverable_desc))
244 | .setPriority(NotificationCompat.PRIORITY_LOW)
245 | .addAction(R.drawable.ic_close, getString(R.string.stop), pi)
246 | .build()
247 | }
248 |
249 | override fun onBind(intent: Intent): IBinder? {
250 | return null
251 | }
252 |
253 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
254 | return START_STICKY
255 | }
256 |
257 | fun startAdv() {
258 | val advertiser = btAdvertiser ?: return
259 |
260 | val advData = AdvertiseData.Builder().apply {
261 | addServiceUuid(ParcelUuid(BleUtils.ADV_SERVICE_UUID))
262 | addServiceData(
263 | ParcelUuid.fromString(
264 | String.format(
265 | "000001ff-0000-1000-8000-00805f9b34fb",
266 | java.lang.Byte.valueOf(0),
267 | java.lang.Byte.valueOf(0),
268 | )
269 | ), Arrays.copyOfRange(BleUtils.RANDOM_DATA, 0, 6)
270 | )
271 | }.build()
272 | val scanRespData = AdvertiseData.Builder().apply {
273 | val data = ByteArray(27)
274 | System.arraycopy(ByteArray(8), 0, data, 0, 8)
275 | System.arraycopy(BleUtils.RANDOM_DATA, 0, data, 8, 2)
276 |
277 | val name = AppSettings(this@GattServerService).deviceName
278 | var nameBytes = name.toByteArray(Charsets.UTF_8)
279 | if (nameBytes.size > 15) {
280 | var str = String(nameBytes.copyOf(15), Charsets.UTF_8)
281 | var length = str.length - 1
282 |
283 | // Scan backwards for char boundary
284 | while (length >= 0 && !name.startsWith(str)) {
285 | str = str.substring(0, length)
286 | length -= 1
287 | }
288 |
289 | nameBytes = (str + "\t").toByteArray(Charsets.UTF_8)
290 | }
291 | System.arraycopy(nameBytes, 0, data, 10, min(nameBytes.size, 16))
292 |
293 | data[26] = 1
294 |
295 | addServiceData(ParcelUuid.fromString("0000ffff-0000-1000-8000-00805f9b34fb"), data)
296 | }.build()
297 |
298 | val params = AdvertisingSetParameters.Builder().apply {
299 | setLegacyMode(true)
300 | setConnectable(true)
301 | setScannable(true)
302 | setInterval(160)
303 | setTxPowerLevel(1)
304 | }.build()
305 |
306 | try {
307 | advertiser.startAdvertisingSet(
308 | params, advData, scanRespData, null, null, 0, 0, advSetCallback
309 | )
310 |
311 | gattServer = btManager.openGattServer(this, gattServerCallback).apply {
312 | addService(buildGattService())
313 | }
314 | } catch (e: SecurityException) {
315 | Log.e(TAG, "Got SecurityException when trying to advertise", e)
316 | stopSelf()
317 | }
318 | }
319 |
320 | private fun buildGattService(): BluetoothGattService {
321 | val svc = BluetoothGattService(
322 | BleUtils.SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY
323 | )
324 | svc.addCharacteristic(
325 | BluetoothGattCharacteristic(BleUtils.CHAR_STATUS_UUID, 10, 17)
326 | )
327 | svc.addCharacteristic(
328 | BluetoothGattCharacteristic(BleUtils.CHAR_P2P_UUID, 10, 17)
329 | )
330 | return svc
331 | }
332 |
333 | @SuppressLint("MissingPermission")
334 | override fun onDestroy() {
335 | super.onDestroy()
336 | if (internalReceiverRegistered) {
337 | unregisterReceiver(internalReceiver)
338 | }
339 | sendBroadcast(ServiceState.getUpdateIntent(false))
340 |
341 | try {
342 | advertisingSet?.run {
343 | btAdvertiser?.stopAdvertisingSet(advSetCallback)
344 | }
345 |
346 | } catch (e: Exception) {
347 | Log.e(TAG, "Failed to stop advertising", e)
348 | }
349 | advertisingSet = null
350 |
351 |
352 | try {
353 | gattServer?.close()
354 | } catch (e: Exception) {
355 | Log.e(TAG, "Failed to stop GATT server", e)
356 | }
357 | gattServer = null
358 | }
359 |
360 | private fun updateMacAddress(mac: String) {
361 | Log.i(TAG, "Updating local MAC address to $mac")
362 | synchronized(localDeviceInfoLock) {
363 | localDeviceInfo = DeviceInfo(
364 | state = localDeviceInfo.state,
365 | mac = mac,
366 | key = localDeviceInfo.key,
367 | catShare = BuildConfig.VERSION_CODE,
368 | )
369 | localDeviceStatusBytes = Json.encodeToString(localDeviceInfo).toByteArray()
370 | }
371 | }
372 |
373 | companion object {
374 | fun getIntent(context: Context): Intent {
375 | return Intent(context, GattServerService::class.java)
376 | }
377 |
378 | fun start(context: Context) {
379 | context.startService(getIntent(context))
380 | }
381 |
382 | fun stop(context: Context) {
383 | context.stopService(getIntent(context))
384 | }
385 | }
386 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/services/MacAddressService.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.services
2 |
3 | import android.util.Log
4 | import moe.reimu.catshare.IMacAddressService
5 | import moe.reimu.catshare.utils.TAG
6 | import java.net.NetworkInterface
7 | import kotlin.system.exitProcess
8 |
9 | class MacAddressService: IMacAddressService.Stub() {
10 | override fun destroy() {
11 | exit()
12 | }
13 |
14 | override fun exit() {
15 | Log.i(TAG, "Exit")
16 | exitProcess(0)
17 | }
18 |
19 | override fun getP2pMacAddress(): String? {
20 | return getMacAddressByName("p2p0")
21 | }
22 |
23 | @OptIn(ExperimentalStdlibApi::class)
24 | override fun getMacAddressByName(name: String): String? {
25 | val ifs = NetworkInterface.getNetworkInterfaces()
26 | for (intf in ifs) {
27 | if (intf.name == name) {
28 | return intf.hardwareAddress?.toHexString(HexFormat {
29 | bytes.byteSeparator = ":"
30 | })
31 | }
32 | }
33 | return null
34 | }
35 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/services/P2pReceiverService.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.services
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.Notification
5 | import android.app.PendingIntent
6 | import android.content.BroadcastReceiver
7 | import android.content.ClipData
8 | import android.content.ClipboardManager
9 | import android.content.ContentValues
10 | import android.content.Context
11 | import android.content.Intent
12 | import android.content.IntentFilter
13 | import android.content.pm.ServiceInfo
14 | import android.graphics.Bitmap
15 | import android.graphics.BitmapFactory
16 | import android.net.wifi.p2p.WifiP2pConfig
17 | import android.net.wifi.p2p.WifiP2pGroup
18 | import android.net.wifi.p2p.WifiP2pInfo
19 | import android.net.wifi.p2p.WifiP2pManager
20 | import android.os.Build
21 | import android.os.Environment
22 | import android.os.Handler
23 | import android.os.IBinder
24 | import android.os.Looper
25 | import android.provider.MediaStore
26 | import android.text.format.Formatter
27 | import android.util.Log
28 | import android.webkit.MimeTypeMap
29 | import android.widget.Toast
30 | import androidx.annotation.DrawableRes
31 | import androidx.core.app.NotificationCompat
32 | import androidx.core.app.NotificationManagerCompat
33 | import io.ktor.client.HttpClient
34 | import io.ktor.client.engine.okhttp.OkHttp
35 | import io.ktor.client.plugins.websocket.WebSockets
36 | import io.ktor.client.plugins.websocket.webSocketSession
37 | import io.ktor.client.request.get
38 | import io.ktor.client.request.prepareGet
39 | import io.ktor.client.statement.bodyAsBytes
40 | import io.ktor.client.statement.bodyAsChannel
41 | import io.ktor.utils.io.jvm.javaio.toInputStream
42 | import io.ktor.websocket.Frame
43 | import io.ktor.websocket.readText
44 | import kotlinx.coroutines.CompletableDeferred
45 | import kotlinx.coroutines.CoroutineScope
46 | import kotlinx.coroutines.Dispatchers
47 | import kotlinx.coroutines.Job
48 | import kotlinx.coroutines.SupervisorJob
49 | import kotlinx.coroutines.async
50 | import kotlinx.coroutines.coroutineScope
51 | import kotlinx.coroutines.delay
52 | import kotlinx.coroutines.launch
53 | import kotlinx.coroutines.selects.select
54 | import kotlinx.coroutines.suspendCancellableCoroutine
55 | import kotlinx.coroutines.withTimeoutOrNull
56 | import moe.reimu.catshare.AppSettings
57 | import moe.reimu.catshare.BuildConfig
58 | import moe.reimu.catshare.FakeTrustManager
59 | import moe.reimu.catshare.MyApplication
60 | import moe.reimu.catshare.R
61 | import moe.reimu.catshare.exceptions.CancelledByUserException
62 | import moe.reimu.catshare.exceptions.ExceptionWithMessage
63 | import moe.reimu.catshare.models.P2pInfo
64 | import moe.reimu.catshare.models.ReceivedFile
65 | import moe.reimu.catshare.models.WebSocketMessage
66 | import moe.reimu.catshare.utils.NotificationUtils
67 | import moe.reimu.catshare.utils.ProgressCounter
68 | import moe.reimu.catshare.utils.TAG
69 | import moe.reimu.catshare.utils.awaitWithTimeout
70 | import moe.reimu.catshare.utils.checkP2pPermissions
71 | import moe.reimu.catshare.utils.connectSuspend
72 | import moe.reimu.catshare.utils.registerInternalBroadcastReceiver
73 | import moe.reimu.catshare.utils.removeGroupSuspend
74 | import moe.reimu.catshare.utils.requestGroupInfo
75 | import moe.reimu.catshare.utils.sendStatusIgnoreException
76 | import okhttp3.ConnectionPool
77 | import org.json.JSONObject
78 | import java.io.File
79 | import java.security.SecureRandom
80 | import java.time.Duration
81 | import java.util.concurrent.TimeUnit
82 | import java.util.zip.ZipInputStream
83 | import javax.net.ssl.SSLContext
84 | import kotlin.math.min
85 | import kotlin.random.Random
86 | import androidx.core.net.toUri
87 | import moe.reimu.catshare.utils.ZipPathValidatorCallback
88 |
89 | class P2pReceiverService : BaseP2pService() {
90 | private lateinit var notificationManager: NotificationManagerCompat
91 |
92 | private val internalReceiver = object : BroadcastReceiver() {
93 | override fun onReceive(context: Context, intent: Intent) {
94 | when (intent.action) {
95 | ACTION_CANCEL_RECEIVING -> {
96 | cancel(intent.getIntExtra("taskId", -1))
97 | }
98 | }
99 | }
100 | }
101 | private var internalReceiverRegistered = false
102 |
103 | override fun onCreate() {
104 | super.onCreate()
105 |
106 | Log.d(TAG, "onCreate")
107 |
108 | if (!checkP2pPermissions()) {
109 | stopSelf()
110 | return
111 | }
112 |
113 | notificationManager = NotificationManagerCompat.from(this)
114 |
115 | registerInternalBroadcastReceiver(internalReceiver, IntentFilter().apply {
116 | addAction(ACTION_CANCEL_RECEIVING)
117 | })
118 | internalReceiverRegistered = true
119 | }
120 |
121 | private var p2pFuture = CompletableDeferred>()
122 |
123 | @Suppress("DEPRECATION")
124 | override fun onP2pBroadcast(intent: Intent) {
125 | when (intent.action) {
126 | WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
127 | val connInfo = intent.getParcelableExtra(
128 | WifiP2pManager.EXTRA_WIFI_P2P_INFO
129 | )!!
130 | val group = intent.getParcelableExtra(
131 | WifiP2pManager.EXTRA_WIFI_P2P_GROUP
132 | )
133 | Log.d(P2pReceiverService.TAG, "P2P info: $connInfo, P2P group: $group")
134 |
135 | if (connInfo.groupFormed && !connInfo.isGroupOwner && group != null) {
136 | p2pFuture.complete(Pair(connInfo, group))
137 | }
138 | }
139 | }
140 | }
141 |
142 |
143 | private val currentTaskLock = Object()
144 | private var currentJob: Job? = null
145 | private var currentTaskId: Int? = null
146 |
147 | override fun onBind(intent: Intent): IBinder? {
148 | return null
149 | }
150 |
151 | @SuppressLint("MissingPermission")
152 | @Suppress("DEPRECATION")
153 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
154 | super.onStartCommand(intent, flags, startId)
155 |
156 | if (intent == null) {
157 | return START_NOT_STICKY
158 | }
159 |
160 | if (!MyApplication.getInstance().setBusy()) {
161 | Log.i(TAG, "Application is busy, skipping")
162 | NotificationUtils.showBusyToast(this)
163 | return START_NOT_STICKY
164 | }
165 |
166 | val info = intent.getParcelableExtra("p2p_info") ?: return START_NOT_STICKY
167 | val localTaskId = Random.nextInt()
168 | val job = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
169 | try {
170 | startForeground(
171 | NotificationUtils.RECEIVER_FG_ID,
172 | createPrepareNotification(getString(R.string.noti_connecting)),
173 | ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
174 | )
175 | runReceive(info, localTaskId)
176 | } catch (e: CancelledByUserException) {
177 | Log.i(TAG, "Cancelled by user")
178 | notificationManager.notify(
179 | Random.nextInt(),
180 | createFailedNotification(e)
181 | )
182 | } catch (e: Throwable) {
183 | Log.e(TAG, "Failed to process task", e)
184 | notificationManager.notify(
185 | Random.nextInt(),
186 | createFailedNotification(e)
187 | )
188 | } finally {
189 | stopForeground(STOP_FOREGROUND_REMOVE)
190 | MyApplication.getInstance().clearBusy()
191 | }
192 | }
193 |
194 | synchronized(currentTaskLock) {
195 | currentTaskId = localTaskId
196 | currentJob = job
197 | }
198 |
199 |
200 | return START_NOT_STICKY
201 | }
202 |
203 | private fun createContentValues(file: File): ContentValues {
204 | val extension = file.extension
205 | val mimeType = if (extension.isNotEmpty()) {
206 | MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
207 | } else null
208 |
209 | return ContentValues().apply {
210 | put(MediaStore.Downloads.DISPLAY_NAME, file.name)
211 | put(MediaStore.Downloads.MIME_TYPE, mimeType ?: "application/octet-stream")
212 | put(
213 | MediaStore.Downloads.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/CatShare"
214 | )
215 | }
216 | }
217 |
218 | private fun createNotificationBuilder(@DrawableRes icon: Int): NotificationCompat.Builder {
219 | return NotificationCompat.Builder(this, NotificationUtils.RECEIVER_CHAN_ID)
220 | .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
221 | .setSmallIcon(icon).setPriority(NotificationCompat.PRIORITY_MAX)
222 | }
223 |
224 | private fun createPrepareNotification(description: String) =
225 | createNotificationBuilder(R.drawable.ic_downloading).setOngoing(true)
226 | .setContentTitle(getString(R.string.preparing_transmission)).setContentText(description)
227 | .build()
228 |
229 | private fun createAskingNotification(
230 | taskId: Int,
231 | senderName: String,
232 | fileName: String,
233 | fileCount: Int,
234 | totalSize: Long,
235 | thumbnail: Bitmap?,
236 | textContent: String?
237 | ): Notification {
238 | val fmtSize = Formatter.formatShortFileSize(this, totalSize)
239 | val contentText = if (textContent == null) {
240 | resources.getQuantityString(
241 | R.plurals.noti_request_desc, fileCount, fileCount, fmtSize
242 | )
243 | } else {
244 | resources.getString(R.string.noti_request_desc_text)
245 | }
246 |
247 | val dismissIntent = PendingIntent.getBroadcast(
248 | this,
249 | taskId,
250 | Intent(ACTION_DISMISSED).apply { putExtra("taskId", taskId) },
251 | PendingIntent.FLAG_IMMUTABLE
252 | )
253 |
254 | val acceptIntent = PendingIntent.getBroadcast(
255 | this,
256 | taskId,
257 | Intent(ACTION_ACCEPTED).apply { putExtra("taskId", taskId) },
258 | PendingIntent.FLAG_IMMUTABLE
259 | )
260 |
261 | val n = createNotificationBuilder(R.drawable.ic_downloading).setContentTitle(senderName)
262 | .setContentText(contentText)
263 | .addAction(R.drawable.ic_done, getString(R.string.accept), acceptIntent)
264 | .addAction(R.drawable.ic_close, getString(R.string.reject), dismissIntent)
265 | .setDeleteIntent(dismissIntent)
266 |
267 | if (thumbnail != null) {
268 | n.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail))
269 | }
270 | if (textContent != null) {
271 | n.setStyle(NotificationCompat.BigTextStyle().bigText(textContent))
272 | }
273 |
274 | return n.build()
275 | }
276 |
277 | private fun createProgressNotification(
278 | taskId: Int, senderName: String, totalSize: Long, processedSize: Long?
279 | ): Notification {
280 | val cancelIntent = PendingIntent.getBroadcast(
281 | this,
282 | taskId,
283 | Intent(ACTION_CANCEL_RECEIVING).apply { putExtra("taskId", taskId) },
284 | PendingIntent.FLAG_IMMUTABLE
285 | )
286 |
287 | val n =
288 | createNotificationBuilder(R.drawable.ic_downloading).setContentTitle(getString(R.string.receiving))
289 | .setSubText(senderName)
290 | .addAction(R.drawable.ic_close, getString(android.R.string.cancel), cancelIntent)
291 | .setOngoing(true).setOnlyAlertOnce(true)
292 | var text = getString(R.string.preparing)
293 |
294 | if (processedSize != null) {
295 | val progress = 100.0 * (processedSize.toDouble() / totalSize.toDouble())
296 | n.setProgress(100, progress.toInt(), false)
297 |
298 | val f1 = Formatter.formatShortFileSize(this, processedSize)
299 | val f2 = Formatter.formatShortFileSize(this, totalSize)
300 | text = "$f1 / $f2 | ${progress.toInt()}%"
301 | } else {
302 | n.setProgress(0, 0, true)
303 | }
304 | n.setContentText(text)
305 |
306 | return n.build()
307 | }
308 |
309 | private fun createCompletedNotification(
310 | senderName: String, receivedFiles: List, isPartial: Boolean
311 | ): Notification {
312 | val style = NotificationCompat.BigTextStyle()
313 | .bigText(receivedFiles.take(5).joinToString("\n") { it.name })
314 | val builder =
315 | createNotificationBuilder(R.drawable.ic_done).setContentTitle(getString(R.string.recv_ok))
316 | .setSubText(senderName).setAutoCancel(true).setContentText(
317 | if (isPartial) {
318 | resources.getQuantityString(
319 | R.plurals.noti_complete_partial, receivedFiles.size, receivedFiles.size
320 | )
321 | } else {
322 | resources.getQuantityString(
323 | R.plurals.noti_complete, receivedFiles.size, receivedFiles.size
324 | )
325 | }
326 | ).setStyle(style)
327 |
328 | val intent = if (receivedFiles.size == 1) {
329 | val rf = receivedFiles.first()
330 | Intent(Intent.ACTION_VIEW).apply {
331 | setDataAndType(rf.uri, rf.mimeType)
332 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
333 | }
334 | } else {
335 | Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
336 | putExtra(
337 | "android.provider.extra.INITIAL_URI",
338 | "content://downloads/public_downloads".toUri()
339 | )
340 | }
341 | }
342 | builder.setContentIntent(
343 | PendingIntent.getActivity(
344 | this, 0, intent, PendingIntent.FLAG_IMMUTABLE
345 | )
346 | )
347 | return builder.build()
348 | }
349 |
350 | private fun createFailedNotification(exception: Throwable?): Notification {
351 | if (AppSettings(this).verbose && exception != null) {
352 | return createNotificationBuilder(R.drawable.ic_warning)
353 | .setContentTitle(getString(R.string.recv_fail))
354 | .setContentText(getString(R.string.expand_for_details))
355 | .setStyle(NotificationCompat.BigTextStyle().bigText(exception.stackTraceToString()))
356 | .setAutoCancel(true).build()
357 | }
358 | return createNotificationBuilder(R.drawable.ic_warning)
359 | .setContentTitle(getString(R.string.recv_fail))
360 | .setContentText(
361 | if (exception != null && exception is ExceptionWithMessage) {
362 | exception.getMessage(this)
363 | } else if (exception != null && exception is CancelledByUserException) {
364 | if (exception.isRemote) {
365 | getString(R.string.cancelled_by_user_remote)
366 | } else {
367 | getString(R.string.cancelled_by_user_local)
368 | }
369 | } else {
370 | getString(R.string.noti_send_interrupted)
371 | }
372 | )
373 | .setAutoCancel(true).build()
374 | }
375 |
376 | @SuppressLint("MissingPermission")
377 | private fun updateNotification(n: Notification) {
378 | notificationManager.notify(NotificationUtils.RECEIVER_FG_ID, n)
379 | }
380 |
381 | @SuppressLint("MissingPermission")
382 | private suspend fun runReceive(p2pInfo: P2pInfo, localTaskId: Int) = coroutineScope {
383 | val client = HttpClient(OkHttp) {
384 | install(WebSockets)
385 | engine {
386 | config {
387 | val sslContext = SSLContext.getInstance("TLSv1.2")
388 | val tm = FakeTrustManager()
389 | sslContext.init(null, arrayOf(tm), SecureRandom())
390 |
391 | connectTimeout(3, TimeUnit.SECONDS)
392 | connectionPool(
393 | ConnectionPool(5, 10, TimeUnit.SECONDS)
394 | )
395 | sslSocketFactory(sslContext.socketFactory, tm)
396 | hostnameVerifier { _, _ -> true }
397 | }
398 | }
399 | }
400 |
401 | val p2pConfig = WifiP2pConfig.Builder()
402 | .setNetworkName(p2pInfo.ssid)
403 | .setPassphrase(p2pInfo.psk)
404 | .build()
405 |
406 | client.use { client ->
407 | p2pFuture = CompletableDeferred()
408 | val groupInfo = p2pManager.requestGroupInfo(p2pChannel)
409 | if (groupInfo != null) {
410 | Log.i(TAG, "A P2P group already exists, trying to remove")
411 | p2pManager.removeGroupSuspend(p2pChannel)
412 | }
413 | p2pManager.connectSuspend(p2pChannel, p2pConfig)
414 | try {
415 | val (wifiP2pInfo, wifiP2pGroup) = p2pFuture.awaitWithTimeout(
416 | Duration.ofSeconds(10), "Waiting for P2P connect", R.string.error_p2p_failed
417 | )
418 |
419 | val hostPort = "${wifiP2pInfo.groupOwnerAddress.hostAddress}:${p2pInfo.port}"
420 |
421 | val sendRequestFuture = CompletableDeferred()
422 | val statusFuture = CompletableDeferred>()
423 |
424 | val wsSession = client.webSocketSession("wss://${hostPort}/websocket")
425 |
426 | val downloadJob = async {
427 | val sendRequestPayload = sendRequestFuture.awaitWithTimeout(
428 | Duration.ofSeconds(5), "Waiting for send request",
429 | R.string.err_recv_req_timeout
430 | )
431 |
432 | val taskId = sendRequestPayload.optString(
433 | "taskId", sendRequestPayload.optString("id")
434 | )
435 | val senderName = sendRequestPayload.getString("senderName")
436 | val fileName = sendRequestPayload.getString("fileName")
437 | val totalSize = sendRequestPayload.getLong("totalSize")
438 | val fileCount = sendRequestPayload.getInt("fileCount")
439 | val textContent = if (sendRequestPayload.has("catShareText")) {
440 | sendRequestPayload.getString("catShareText")
441 | } else {
442 | null
443 | }
444 |
445 | val thumbPath = sendRequestPayload.optString("thumbnail")
446 | val bigPicture = if (thumbPath.isNotEmpty()) {
447 | val thumbUrl = "https://${hostPort}$thumbPath"
448 | Log.d(TAG, "Fetching thumbnail from $thumbUrl")
449 |
450 | val body = client.get(thumbUrl).bodyAsBytes()
451 | BitmapFactory.decodeByteArray(body, 0, body.size)
452 | } else null
453 |
454 | updateNotification(
455 | createAskingNotification(
456 | localTaskId,
457 | senderName,
458 | fileName,
459 | fileCount,
460 | totalSize,
461 | bigPicture,
462 | textContent
463 | )
464 | )
465 |
466 | val userResponse = withTimeoutOrNull(10000L) {
467 | waitForAction(localTaskId)
468 | }
469 |
470 | if (userResponse != true) {
471 | wsSession.sendStatusIgnoreException(
472 | 99,
473 | taskId,
474 | 3,
475 | "user refuse"
476 | )
477 | throw CancelledByUserException(false)
478 | }
479 |
480 | if (textContent != null) {
481 | val cm = getSystemService(ClipboardManager::class.java)
482 | cm.setPrimaryClip(ClipData.newPlainText("Shared Text", textContent))
483 |
484 | showTextCopiedToast()
485 |
486 | wsSession.sendStatusIgnoreException(99, taskId, 1, "ok")
487 | delay(1000)
488 | return@async
489 | }
490 |
491 | updateNotification(
492 | createProgressNotification(
493 | localTaskId, senderName, totalSize, null
494 | )
495 | )
496 |
497 | val downloadUrl = "https://${hostPort}/download?taskId=${taskId}"
498 |
499 | val files = client.prepareGet(downloadUrl).execute { downloadRes ->
500 | val ist = downloadRes.bodyAsChannel().toInputStream()
501 |
502 | val progress = ProgressCounter(totalSize) { total, processed ->
503 | updateNotification(
504 | createProgressNotification(
505 | localTaskId,
506 | senderName,
507 | total,
508 | processed
509 | )
510 | )
511 | }
512 |
513 | ZipInputStream(ist).use { zipStream ->
514 | saveArchive(zipStream, progress)
515 | }
516 | }
517 |
518 | if (files.isNotEmpty()) {
519 | notificationManager.notify(
520 | Random.nextInt(), createCompletedNotification(
521 | senderName, files, files.size != fileCount
522 | )
523 | )
524 | wsSession.sendStatusIgnoreException(99, taskId, 1, "ok")
525 | delay(1000)
526 | } else {
527 | throw IllegalStateException("Failed to receive any file")
528 | }
529 | }
530 |
531 | while (true) {
532 | val run = select {
533 | wsSession.incoming.onReceive { frame ->
534 | val text = (frame as? Frame.Text)?.readText()
535 | ?: throw IllegalArgumentException("Got non-text frame")
536 | val message = WebSocketMessage.fromText(text)
537 | ?: throw IllegalArgumentException("Failed to parse message")
538 |
539 | Log.d(TAG, "WS message: $message")
540 |
541 | if (message.type != "action") {
542 | return@onReceive true// We only care about `action`
543 | }
544 |
545 | val payload = message.payload ?: return@onReceive true
546 |
547 | val r = when (message.name.lowercase()) {
548 | "versionnegotiation" -> {
549 | val inVersion = payload.optInt("version", 1)
550 | val currentVersion = min(inVersion, 1)
551 |
552 | JSONObject()
553 | .put("version", currentVersion)
554 | .put("threadLimit", 5)
555 | }
556 |
557 | "sendrequest" -> {
558 | sendRequestFuture.complete(payload)
559 | null
560 | }
561 |
562 | "status" -> {
563 | statusFuture.complete(
564 | Pair(
565 | payload.optInt("type"), payload.optString("reason")
566 | )
567 | )
568 | null
569 | }
570 |
571 | else -> {
572 | null
573 | }
574 | }
575 |
576 | val ack = WebSocketMessage("ack", message.id, message.name, r)
577 | wsSession.send(Frame.Text(ack.toText()))
578 | true
579 | }
580 | downloadJob.onAwait {
581 | // Completed successfully
582 | false
583 | }
584 | statusFuture.onAwait { status ->
585 | if (status.first == 3 && status.second == "user refuse") {
586 | throw CancelledByUserException(true)
587 | }
588 | throw RuntimeException("Transfer terminated with $status")
589 | }
590 | }
591 |
592 | if (!run) {
593 | break
594 | }
595 | }
596 | } finally {
597 | p2pManager.removeGroup(p2pChannel, null)
598 | p2pManager.cancelConnect(p2pChannel, null)
599 | }
600 | }
601 | }
602 |
603 | private fun saveArchive(
604 | zipStream: ZipInputStream,
605 | progress: ProgressCounter
606 | ): List {
607 | val receivedFiles = mutableListOf()
608 | var processedSize = 0L
609 |
610 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
611 | // Disable validation as we only use the file name anyway
612 | dalvik.system.ZipPathValidator.setCallback(ZipPathValidatorCallback)
613 | }
614 |
615 | while (true) {
616 | val entry = zipStream.nextEntry ?: break
617 | if (entry.isDirectory) {
618 | continue
619 | }
620 |
621 | Log.d(P2pReceiverService.TAG, "Entry ${entry.name}")
622 |
623 | val entryFile = File(entry.name)
624 | val values = createContentValues(entryFile)
625 |
626 | try {
627 | val uri = contentResolver.insert(
628 | MediaStore.Downloads.EXTERNAL_CONTENT_URI, values
629 | )
630 | ?: throw RuntimeException("Failed to write ${entryFile.name} to media store")
631 |
632 | try {
633 | val os = contentResolver.openOutputStream(uri)
634 | ?: throw RuntimeException("Failed to open ${entryFile.name}")
635 | val buffer = ByteArray(1024 * 1024 * 4)
636 |
637 | os.use {
638 | while (true) {
639 | val readLen = zipStream.read(buffer)
640 | if (readLen == -1) {
641 | break
642 | }
643 | os.write(buffer, 0, readLen)
644 |
645 | processedSize += readLen.toLong()
646 | progress.update(processedSize)
647 | }
648 | }
649 |
650 | receivedFiles.add(
651 | ReceivedFile(
652 | entryFile.name,
653 | uri,
654 | values.getAsString(MediaStore.Downloads.MIME_TYPE)
655 | )
656 | )
657 | } catch (e: Throwable) {
658 | // Remove failed files
659 | contentResolver.delete(uri, null, null)
660 | throw e
661 | }
662 | } catch (e: Throwable) {
663 | Log.e(TAG, "Failed to receive ${entryFile.name}, stopping", e)
664 | break
665 | }
666 | }
667 |
668 | Log.d(TAG, "Received ${receivedFiles.size} files")
669 |
670 | return receivedFiles
671 | }
672 |
673 | private suspend fun waitForAction(taskId: Int) = suspendCancellableCoroutine {
674 | val receiver = object : BroadcastReceiver() {
675 | override fun onReceive(context: Context, intent: Intent) {
676 | if (intent.getIntExtra("taskId", -1) != taskId) {
677 | return
678 | }
679 |
680 | when (intent.action) {
681 | ACTION_ACCEPTED -> it.resume(true) {}
682 | ACTION_DISMISSED -> it.resume(false) {}
683 | }
684 | }
685 | }
686 |
687 | registerInternalBroadcastReceiver(receiver, IntentFilter().apply {
688 | addAction(ACTION_ACCEPTED)
689 | addAction(ACTION_DISMISSED)
690 | })
691 |
692 | it.invokeOnCancellation {
693 | unregisterReceiver(receiver)
694 | }
695 | }
696 |
697 | fun cancel(taskId: Int) {
698 | synchronized(currentTaskLock) {
699 | if (currentTaskId == taskId) {
700 | currentJob?.cancel(CancelledByUserException(false))
701 | }
702 | }
703 | }
704 |
705 | override fun onDestroy() {
706 | super.onDestroy()
707 |
708 | Log.d(TAG, "onDestroy")
709 |
710 | if (internalReceiverRegistered) {
711 | unregisterReceiver(internalReceiver)
712 | }
713 | }
714 |
715 | private fun showTextCopiedToast() {
716 | Handler(Looper.getMainLooper()).post {
717 | Toast.makeText(
718 | this@P2pReceiverService,
719 | R.string.msg_copied_to_clipboard,
720 | Toast.LENGTH_SHORT
721 | ).show()
722 | }
723 | }
724 |
725 | companion object {
726 | fun getIntent(context: Context, p2pInfo: P2pInfo): Intent {
727 | return Intent(context, P2pReceiverService::class.java).apply {
728 | putExtra("p2p_info", p2pInfo)
729 | }
730 | }
731 |
732 | private val ACTION_DISMISSED = "${BuildConfig.APPLICATION_ID}.NOTIFICATION_DISMISSED"
733 | private val ACTION_ACCEPTED = "${BuildConfig.APPLICATION_ID}.NOTIFICATION_ACCEPTED"
734 | private val ACTION_CANCEL_RECEIVING = "${BuildConfig.APPLICATION_ID}.CANCEL_RECEIVING"
735 | }
736 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/services/P2pSenderService.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.services
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.Notification
5 | import android.app.PendingIntent
6 | import android.content.BroadcastReceiver
7 | import android.content.Context
8 | import android.content.Intent
9 | import android.content.IntentFilter
10 | import android.content.pm.ServiceInfo
11 | import android.net.wifi.p2p.WifiP2pConfig
12 | import android.net.wifi.p2p.WifiP2pDevice
13 | import android.net.wifi.p2p.WifiP2pDeviceList
14 | import android.net.wifi.p2p.WifiP2pGroup
15 | import android.net.wifi.p2p.WifiP2pInfo
16 | import android.net.wifi.p2p.WifiP2pManager
17 | import android.os.Binder
18 | import android.text.format.Formatter
19 | import android.util.Log
20 | import androidx.annotation.DrawableRes
21 | import androidx.core.app.NotificationCompat
22 | import androidx.core.app.NotificationManagerCompat
23 | import io.ktor.http.ContentType
24 | import io.ktor.http.HttpStatusCode
25 | import io.ktor.network.tls.certificates.buildKeyStore
26 | import io.ktor.server.application.install
27 | import io.ktor.server.engine.embeddedServer
28 | import io.ktor.server.engine.sslConnector
29 | import io.ktor.server.netty.Netty
30 | import io.ktor.server.response.respondOutputStream
31 | import io.ktor.server.response.respondText
32 | import io.ktor.server.routing.get
33 | import io.ktor.server.routing.routing
34 | import io.ktor.server.websocket.WebSockets
35 | import io.ktor.server.websocket.webSocket
36 | import io.ktor.websocket.Frame
37 | import io.ktor.websocket.readText
38 | import kotlinx.coroutines.CompletableDeferred
39 | import kotlinx.coroutines.CoroutineScope
40 | import kotlinx.coroutines.Dispatchers
41 | import kotlinx.coroutines.Job
42 | import kotlinx.coroutines.SupervisorJob
43 | import kotlinx.coroutines.async
44 | import kotlinx.coroutines.coroutineScope
45 | import kotlinx.coroutines.delay
46 | import kotlinx.coroutines.launch
47 | import kotlinx.coroutines.selects.select
48 | import kotlinx.coroutines.withTimeoutOrNull
49 | import kotlinx.serialization.encodeToString
50 | import kotlinx.serialization.json.Json
51 | import moe.reimu.catshare.AppSettings
52 | import moe.reimu.catshare.BleSecurity
53 | import moe.reimu.catshare.BuildConfig
54 | import moe.reimu.catshare.exceptions.CancelledByUserException
55 | import moe.reimu.catshare.MyApplication
56 | import moe.reimu.catshare.R
57 | import moe.reimu.catshare.exceptions.ExceptionWithMessage
58 | import moe.reimu.catshare.models.DeviceInfo
59 | import moe.reimu.catshare.models.P2pInfo
60 | import moe.reimu.catshare.models.TaskInfo
61 | import moe.reimu.catshare.models.WebSocketMessage
62 | import moe.reimu.catshare.utils.BleUtils
63 | import moe.reimu.catshare.utils.DeviceUtils
64 | import moe.reimu.catshare.utils.JsonWithUnknownKeys
65 | import moe.reimu.catshare.utils.NotificationUtils
66 | import moe.reimu.catshare.utils.ShizukuUtils
67 | import moe.reimu.catshare.utils.TAG
68 | import moe.reimu.catshare.utils.awaitWithTimeout
69 | import moe.reimu.catshare.utils.createGroupSuspend
70 | import moe.reimu.catshare.utils.registerInternalBroadcastReceiver
71 | import moe.reimu.catshare.utils.removeGroupSuspend
72 | import moe.reimu.catshare.utils.requestGroupInfo
73 | import moe.reimu.catshare.utils.withTimeoutReason
74 | import no.nordicsemi.android.kotlin.ble.client.main.callback.ClientBleGatt
75 | import no.nordicsemi.android.kotlin.ble.core.RealServerDevice
76 | import no.nordicsemi.android.kotlin.ble.core.data.util.DataByteArray
77 | import org.json.JSONObject
78 | import java.time.Duration
79 | import java.util.concurrent.TimeUnit
80 | import java.util.concurrent.TimeoutException
81 | import java.util.zip.ZipEntry
82 | import java.util.zip.ZipOutputStream
83 | import kotlin.random.Random
84 |
85 | class P2pSenderService : BaseP2pService() {
86 | private val binder = LocalBinder()
87 |
88 | inner class LocalBinder : Binder() {
89 | fun getService(): P2pSenderService = this@P2pSenderService
90 | }
91 |
92 | override fun onBind(intent: Intent) = binder
93 |
94 | private var groupInfoFuture = CompletableDeferred()
95 |
96 | private val currentTaskLock = Object()
97 | private var currentJob: Job? = null
98 | private var curreentTaskId: Int? = null
99 |
100 | private lateinit var notificationManager: NotificationManagerCompat
101 |
102 | private val internalReceiver = object : BroadcastReceiver() {
103 | override fun onReceive(context: Context, intent: Intent) {
104 | when (intent.action) {
105 | ACTION_CANCEL_SENDING -> {
106 | cancel(intent.getIntExtra("taskId", -1))
107 | }
108 | }
109 | }
110 | }
111 |
112 | override fun onCreate() {
113 | super.onCreate()
114 |
115 | notificationManager = NotificationManagerCompat.from(this)
116 |
117 | registerInternalBroadcastReceiver(internalReceiver, IntentFilter().apply {
118 | addAction(ACTION_CANCEL_SENDING)
119 | })
120 | }
121 |
122 | @Suppress("DEPRECATION")
123 | override fun onP2pBroadcast(intent: Intent) {
124 | when (intent.action) {
125 | WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
126 | val connInfo = intent.getParcelableExtra(
127 | WifiP2pManager.EXTRA_WIFI_P2P_INFO
128 | )!!
129 | val group = intent.getParcelableExtra(
130 | WifiP2pManager.EXTRA_WIFI_P2P_GROUP
131 | )
132 |
133 | if (group != null) {
134 | groupInfoFuture.complete(group)
135 | }
136 |
137 | Log.d(P2pSenderService.TAG, "P2P info: $connInfo, P2P group: $group")
138 | }
139 |
140 | WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> {
141 | val peers =
142 | intent.getParcelableExtra(WifiP2pManager.EXTRA_P2P_DEVICE_LIST)!!
143 | Log.d(P2pSenderService.TAG, "P2P peers: $peers")
144 | }
145 |
146 | WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> {
147 | val device =
148 | intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE)!!
149 | Log.d(P2pSenderService.TAG, "Local P2P device: $device")
150 | }
151 | }
152 | }
153 |
154 | @SuppressLint("MissingPermission")
155 | suspend fun runTask(task: TaskInfo) = coroutineScope {
156 | val taskIdStr = task.id.toString()
157 | var totalSize = 0L
158 | var fileCount = 0
159 | var mimeType: String? = null
160 |
161 | for (fi in task.files) {
162 | totalSize += fi.size
163 | fileCount += 1
164 |
165 | if (mimeType == null) {
166 | mimeType = fi.mimeType
167 | } else if (mimeType != fi.mimeType) {
168 | mimeType = "*/*"
169 | }
170 | }
171 |
172 | val settings = AppSettings(this@P2pSenderService)
173 |
174 | val taskObj =
175 | JSONObject()
176 | .put("taskId", taskIdStr)
177 | .put("id", taskIdStr)
178 | .put("senderId", BleUtils.getSenderId())
179 | .put("senderName", settings.deviceName)
180 | .put("fileName", task.files.first().name)
181 | .put("mimeType", mimeType)
182 | .put("fileCount", fileCount)
183 | .put("totalSize", totalSize)
184 |
185 | val sharedTextContent = if (task.files.size == 1 && task.files[0].textContent != null) {
186 | val tc = task.files[0].textContent
187 | taskObj.put("catShareText", tc)
188 | tc
189 | } else {
190 | null
191 | }
192 |
193 | val websocketConnectFuture = CompletableDeferred()
194 | val handshakeCompleteFuture = CompletableDeferred()
195 | val transferStartFuture = CompletableDeferred()
196 | val statusFuture = CompletableDeferred>()
197 | val transferCompleteFuture = CompletableDeferred()
198 | val wsCloseFuture = CompletableDeferred()
199 |
200 | val httpServer = embeddedServer(Netty, configure = {
201 | val keyStore = buildKeyStore {
202 | certificate("sampleAlias") {
203 | password = "foobar"
204 | domains = listOf("127.0.0.1", "0.0.0.0", "localhost")
205 | }
206 | }
207 |
208 | sslConnector(keyStore = keyStore,
209 | keyAlias = "sampleAlias",
210 | keyStorePassword = { "123456".toCharArray() },
211 | privateKeyPassword = { "foobar".toCharArray() }) {
212 | port = 0
213 | }
214 |
215 | enableHttp2 = false
216 | }) {
217 | install(WebSockets)
218 |
219 | routing {
220 | webSocket("/websocket") {
221 | Log.i(TAG, "Got WS request from ${call.request.local.remoteAddress}")
222 | websocketConnectFuture.complete(Unit)
223 |
224 | val versionNegotiationFuture = CompletableDeferred()
225 |
226 | launch {
227 | try {
228 | while (true) {
229 | val rawMessage = incoming.receive() as? Frame.Text
230 | ?: throw IllegalArgumentException("Invalid frame type")
231 | val message = WebSocketMessage.fromText(rawMessage.readText())
232 | ?: throw IllegalArgumentException("Failed to parse message")
233 | Log.d(P2pSenderService.TAG, "Incoming message: $message")
234 |
235 | when (message.type) {
236 | "action" -> {
237 | if (message.name.contentEquals("status")) {
238 | val payload = message.payload ?: continue
239 | statusFuture.complete(
240 | Pair(
241 | payload.optInt("type"),
242 | payload.optString("reason")
243 | )
244 | )
245 | }
246 |
247 | val ackMsg = WebSocketMessage(
248 | "ack", message.id, message.name, null
249 | )
250 | send(Frame.Text(ackMsg.toText()))
251 | }
252 |
253 | "ack" -> {
254 | val isVn = message.name.contentEquals(
255 | ACTION_VERSION_NEGOTIATION, true
256 | )
257 | if (isVn) {
258 | versionNegotiationFuture.complete(Unit)
259 | }
260 | }
261 | }
262 | }
263 | } catch (e: Throwable) {
264 | Log.e(TAG, "WebSocket failed", e)
265 | throw e
266 | } finally {
267 | outgoing.close()
268 | }
269 | }
270 |
271 | send(
272 | Frame.Text(
273 | WebSocketMessage(
274 | "action",
275 | 0,
276 | "versionNegotiation",
277 | JSONObject()
278 | .put("version", 1)
279 | .put("versions", listOf(1))
280 | ).toText()
281 | )
282 | )
283 | versionNegotiationFuture.await()
284 | send(
285 | Frame.Text(
286 | WebSocketMessage(
287 | "action", 1, "sendRequest", taskObj
288 | ).toText()
289 | )
290 | )
291 | handshakeCompleteFuture.complete(Unit)
292 |
293 | wsCloseFuture.await()
294 | }
295 |
296 | get("/download") {
297 | Log.i(TAG, "Got download request from ${call.request.local.remoteAddress}")
298 | transferStartFuture.complete(Unit)
299 |
300 | if (call.request.queryParameters["taskId"] != taskIdStr) {
301 | call.respondText(
302 | "Task ID not found",
303 | ContentType.Text.Plain,
304 | HttpStatusCode.NotFound
305 | )
306 | return@get
307 | }
308 |
309 | var processedSize = 0L
310 | var lastProgressUpdate = 0L
311 |
312 | call.respondOutputStream(ContentType.Application.Zip, HttpStatusCode.OK) {
313 | val cr = contentResolver
314 | ZipOutputStream(this).use { zo ->
315 | if (sharedTextContent != null) {
316 | zo.putNextEntry(ZipEntry("0/sharedText.txt"))
317 | zo.write(sharedTextContent.toByteArray(Charsets.UTF_8))
318 | zo.closeEntry()
319 | return@use
320 | }
321 |
322 | for ((i, rf) in task.files.withIndex()) {
323 | cr.openInputStream(rf.uri)!!.use { ist ->
324 | zo.putNextEntry(ZipEntry("$i/${rf.name}"))
325 |
326 | val buffer = ByteArray(1024 * 1024 * 4)
327 | while (true) {
328 | val readLen = ist.read(buffer)
329 | if (readLen == -1) {
330 | break
331 | }
332 | zo.write(buffer, 0, readLen)
333 | processedSize += readLen.toLong()
334 |
335 | // Update progress if needed
336 | val now = System.nanoTime()
337 | val elapsed = TimeUnit.SECONDS.convert(
338 | now - lastProgressUpdate, TimeUnit.NANOSECONDS
339 | )
340 | if (elapsed > 1) {
341 | updateNotification(
342 | createProgressNotification(
343 | task.id,
344 | task.device.name,
345 | totalSize,
346 | processedSize
347 | )
348 | )
349 | lastProgressUpdate = now
350 | }
351 | }
352 |
353 | zo.closeEntry()
354 | }
355 | }
356 | }
357 | transferCompleteFuture.complete(Unit)
358 | }
359 | }
360 | }
361 | }
362 |
363 | try {
364 | httpServer.start()
365 | val serverPort = httpServer.engine.resolvedConnectors().first().port
366 | Log.d(TAG, "HTTP server listening on $serverPort")
367 |
368 | val groupInfo = p2pManager.requestGroupInfo(p2pChannel)
369 | if (groupInfo != null) {
370 | Log.i(TAG, "Removing existing group: $groupInfo")
371 | p2pManager.removeGroupSuspend(p2pChannel)
372 | }
373 |
374 | val ssid = "DIRECT-${DeviceUtils.getRandomChars(8)}"
375 | val psk = DeviceUtils.getRandomChars(8)
376 |
377 | val p2pConfig = WifiP2pConfig.Builder().setGroupOperatingBand(
378 | if (task.device.supports5Ghz) {
379 | WifiP2pConfig.GROUP_OWNER_BAND_AUTO
380 | } else {
381 | WifiP2pConfig.GROUP_OWNER_BAND_2GHZ
382 | }
383 | ).setNetworkName(ssid).setPassphrase(psk).enablePersistentMode(false).build()
384 |
385 | try {
386 | groupInfoFuture = CompletableDeferred()
387 | p2pManager.createGroupSuspend(p2pChannel, p2pConfig)
388 | groupInfoFuture.awaitWithTimeout(
389 | Duration.ofSeconds(5),
390 | "Waiting for P2P group info",
391 | R.string.error_p2p_failed
392 | )
393 |
394 | val p2pMac = ShizukuUtils.getMacAddress(this@P2pSenderService, "p2p0") ?: "02:00:00:00:00:00"
395 | Log.d(TAG, "Advertised local MAC address: $p2pMac")
396 |
397 | withTimeoutReason(
398 | Duration.ofSeconds(10),
399 | "BLE operations",
400 | R.string.error_bt_failed
401 | ) {
402 | var gBleClient: ClientBleGatt? = null
403 | try {
404 | val bleClient = ClientBleGatt.connect(
405 | this@P2pSenderService,
406 | RealServerDevice(task.device.device),
407 | this@withTimeoutReason,
408 | )
409 | gBleClient = bleClient
410 |
411 | bleClient.requestMtu(512)
412 | val services = bleClient.discoverServices()
413 | val p2pService = services.findService(BleUtils.SERVICE_UUID)
414 | ?: throw IllegalStateException("BLE service not found")
415 | val deviceInfoChar =
416 | p2pService.findCharacteristic(BleUtils.CHAR_STATUS_UUID)
417 | ?: throw IllegalStateException("BLE device info char not found")
418 | val p2pInfoChar = p2pService.findCharacteristic(BleUtils.CHAR_P2P_UUID)
419 | ?: throw IllegalStateException("BLE P2P info char not found")
420 | val rdInfo: DeviceInfo =
421 | JsonWithUnknownKeys.decodeFromString(deviceInfoChar.read().value.decodeToString())
422 | Log.i(TAG, "Remote device: $rdInfo")
423 |
424 | val cipher = rdInfo.key?.let {
425 | BleSecurity.deriveSessionKey(it)
426 | }
427 |
428 | val newP2pInfo = P2pInfo(
429 | id = BleUtils.getSenderId(),
430 | ssid = cipher?.encrypt(ssid) ?: ssid,
431 | psk = cipher?.encrypt(psk) ?: psk,
432 | mac = cipher?.encrypt(p2pMac) ?: p2pMac,
433 | key = if (cipher != null) {
434 | BleSecurity.getEncodedPublicKey()
435 | } else {
436 | null
437 | },
438 | port = serverPort,
439 | catShare = BuildConfig.VERSION_CODE,
440 | )
441 |
442 | p2pInfoChar.write(
443 | DataByteArray(
444 | Json.encodeToString(newP2pInfo).toByteArray()
445 | )
446 | )
447 | } finally {
448 | gBleClient?.close()
449 | }
450 | }
451 |
452 | val transferJob = async {
453 | websocketConnectFuture.awaitWithTimeout(
454 | Duration.ofSeconds(10),
455 | "Waiting for WS connect",
456 | R.string.error_send_timeout_ws
457 | )
458 | handshakeCompleteFuture.awaitWithTimeout(
459 | Duration.ofSeconds(5),
460 | "Waiting for handshake",
461 | R.string.error_send_timeout_handshake
462 | )
463 | transferStartFuture.awaitWithTimeout(
464 | Duration.ofSeconds(30),
465 | "Waiting for start transfer",
466 | R.string.error_send_timeout_handshake
467 | )
468 | transferCompleteFuture.await()
469 | withTimeoutOrNull(5000L) {
470 | statusFuture.await()
471 | }
472 | }
473 | val status = select {
474 | statusFuture.onAwait { it }
475 | transferJob.onAwait { it }
476 | }
477 |
478 | if (status != null) {
479 | if (status.first == 3 && status.second == "user refuse") {
480 | throw CancelledByUserException(true)
481 | }
482 | if (status.first == 1) {
483 | delay(1000)
484 | transferJob.cancel()
485 | return@coroutineScope
486 | }
487 | throw RuntimeException("Transfer terminated with $status")
488 | } else {
489 | throw TimeoutException("Status timed out")
490 | }
491 | } finally {
492 | try {
493 | p2pManager.removeGroupSuspend(p2pChannel)
494 | } catch (e: Throwable) {
495 | // Ignore
496 | e.printStackTrace()
497 | }
498 | }
499 | } finally {
500 | wsCloseFuture.complete(Unit)
501 | httpServer.stop(1000, 1000)
502 | }
503 | }
504 |
505 | @SuppressLint("MissingPermission")
506 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
507 | super.onStartCommand(intent, flags, startId)
508 |
509 | if (intent == null) {
510 | return START_NOT_STICKY
511 | }
512 |
513 | if (!MyApplication.getInstance().setBusy()) {
514 | Log.i(TAG, "Application is busy, skipping")
515 | NotificationUtils.showBusyToast(this)
516 | return START_NOT_STICKY
517 | }
518 |
519 | val task = intent.getParcelableExtra("task") ?: return START_NOT_STICKY
520 | val job = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
521 | try {
522 | startForeground(
523 | NotificationUtils.SENDER_FG_ID,
524 | createPendingNotification(task.id, task.device.name),
525 | ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
526 | )
527 | runTask(task)
528 | notificationManager.notify(
529 | Random.nextInt(),
530 | createCompletedNotification(task.device.name)
531 | )
532 | } catch (e: CancelledByUserException) {
533 | Log.i(TAG, "Cancelled by user")
534 | notificationManager.notify(
535 | Random.nextInt(),
536 | createFailedNotification(task.device.name, e)
537 | )
538 | } catch (e: Throwable) {
539 | Log.e(TAG, "Failed to process task", e)
540 | notificationManager.notify(
541 | Random.nextInt(),
542 | createFailedNotification(task.device.name, e)
543 | )
544 | } finally {
545 | stopForeground(STOP_FOREGROUND_REMOVE)
546 | MyApplication.getInstance().clearBusy()
547 | synchronized(currentTaskLock) {
548 | curreentTaskId = null
549 | currentJob = null
550 | }
551 | }
552 | }
553 |
554 | synchronized(currentTaskLock) {
555 | curreentTaskId = task.id
556 | currentJob = job
557 | }
558 |
559 | return START_NOT_STICKY
560 | }
561 |
562 | fun cancel(taskId: Int) {
563 | synchronized(currentTaskLock) {
564 | if (curreentTaskId == taskId) {
565 | currentJob?.cancel(CancelledByUserException(false))
566 | }
567 | }
568 | }
569 |
570 | private fun createNotificationBuilder(@DrawableRes icon: Int): NotificationCompat.Builder {
571 | return NotificationCompat.Builder(this, NotificationUtils.SENDER_CHAN_ID)
572 | .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
573 | .setSmallIcon(icon)
574 | .setPriority(NotificationCompat.PRIORITY_MAX)
575 | }
576 |
577 | private fun createCancelSendingAction(taskId: Int) = NotificationCompat.Action.Builder(
578 | R.drawable.ic_close,
579 | getString(android.R.string.cancel),
580 | PendingIntent.getBroadcast(
581 | this,
582 | taskId,
583 | Intent(ACTION_CANCEL_SENDING).putExtra("taskId", taskId),
584 | PendingIntent.FLAG_IMMUTABLE
585 | )
586 | ).build()
587 |
588 | private fun createPendingNotification(taskId: Int, targetName: String) =
589 | createNotificationBuilder(R.drawable.ic_upload_file)
590 | .setSubText(targetName)
591 | .setContentTitle(getString(R.string.preparing_transmission))
592 | .setContentText(getString(R.string.noti_connecting))
593 | .addAction(createCancelSendingAction(taskId))
594 | .setOngoing(true).build()
595 |
596 | private fun createProgressNotification(
597 | taskId: Int,
598 | targetName: String,
599 | totalSize: Long,
600 | processedSize: Long
601 | ): Notification {
602 | val n = createNotificationBuilder(R.drawable.ic_upload_file)
603 | .setContentTitle(getString(R.string.sending))
604 | .setSubText(targetName)
605 | .addAction(createCancelSendingAction(taskId))
606 | .setOnlyAlertOnce(true)
607 | .setOngoing(true)
608 |
609 | val progress = 100.0 * (processedSize.toDouble() / totalSize.toDouble())
610 | n.setProgress(100, progress.toInt(), false)
611 |
612 | val f1 = Formatter.formatShortFileSize(this, processedSize)
613 | val f2 = Formatter.formatShortFileSize(this, totalSize)
614 | n.setContentText("$f1 / $f2 | ${progress.toInt()}%")
615 |
616 | return n.build()
617 | }
618 |
619 | private fun createFailedNotification(targetName: String, exception: Throwable?): Notification {
620 | if (AppSettings(this).verbose && exception != null) {
621 | return createNotificationBuilder(R.drawable.ic_warning)
622 | .setContentTitle(getString(R.string.send_fail))
623 | .setSubText(targetName)
624 | .setContentText(getString(R.string.expand_for_details))
625 | .setStyle(NotificationCompat.BigTextStyle().bigText(exception.stackTraceToString()))
626 | .setAutoCancel(true)
627 | .build()
628 | }
629 | return createNotificationBuilder(R.drawable.ic_warning)
630 | .setContentTitle(getString(R.string.send_fail))
631 | .setSubText(targetName)
632 | .setContentText(
633 | if (exception != null && exception is ExceptionWithMessage) {
634 | exception.getMessage(this)
635 | } else if (exception != null && exception is CancelledByUserException) {
636 | if (exception.isRemote) {
637 | getString(R.string.cancelled_by_user_remote)
638 | } else {
639 | getString(R.string.cancelled_by_user_local)
640 | }
641 | } else {
642 | getString(R.string.noti_send_interrupted)
643 | }
644 | )
645 | .setAutoCancel(true)
646 | .build()
647 | }
648 |
649 | private fun createCompletedNotification(targetName: String) =
650 | createNotificationBuilder(R.drawable.ic_done)
651 | .setContentTitle(getString(R.string.send_ok))
652 | .setSubText(targetName)
653 | .setAutoCancel(true)
654 | .build()
655 |
656 | @SuppressLint("MissingPermission")
657 | private fun updateNotification(n: Notification) {
658 | notificationManager.notify(NotificationUtils.SENDER_FG_ID, n)
659 | }
660 |
661 | companion object {
662 | private const val ACTION_VERSION_NEGOTIATION = "versionNegotiation"
663 |
664 | private const val ACTION_CANCEL_SENDING = "${BuildConfig.APPLICATION_ID}.CANCEL_SENDING"
665 |
666 | fun getIntent(context: Context, task: TaskInfo): Intent {
667 | return Intent(context, P2pSenderService::class.java).apply {
668 | putExtra("task", task)
669 | }
670 | }
671 |
672 | fun startTaskChecked(context: Context, task: TaskInfo): Boolean {
673 | if (MyApplication.getInstance().getBusy()) {
674 | NotificationUtils.showBusyToast(context)
675 | return false
676 | }
677 | context.startService(getIntent(context, task))
678 | return true
679 | }
680 | }
681 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/services/ReceiverTileService.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.services
2 |
3 |
4 | import android.annotation.SuppressLint
5 | import android.app.PendingIntent
6 | import android.content.BroadcastReceiver
7 | import android.content.Context
8 | import android.content.Intent
9 | import android.content.IntentFilter
10 | import android.os.Build
11 | import android.service.quicksettings.Tile
12 | import android.service.quicksettings.TileService
13 | import android.util.Log
14 | import moe.reimu.catshare.StartReceiverActivity
15 | import moe.reimu.catshare.utils.ServiceState
16 | import moe.reimu.catshare.utils.TAG
17 | import moe.reimu.catshare.utils.registerInternalBroadcastReceiver
18 | import java.lang.ref.WeakReference
19 | import kotlin.random.Random
20 |
21 | class ReceiverTileService : TileService() {
22 | private class MyReceiver(tileService: ReceiverTileService) : BroadcastReceiver() {
23 | private val serviceRef = WeakReference(tileService)
24 |
25 | override fun onReceive(context: Context, intent: Intent) {
26 | if (intent.action == ServiceState.ACTION_UPDATE_RECEIVER_STATE) {
27 | serviceRef.get()?.setState(
28 | intent.getBooleanExtra("isRunning", false)
29 | )
30 | }
31 | }
32 |
33 | }
34 |
35 | private var receiver: MyReceiver? = null
36 |
37 | @SuppressLint("StartActivityAndCollapseDeprecated")
38 | @Suppress("DEPRECATION")
39 | override fun onClick() {
40 | val intent = when (qsTile.state) {
41 | Tile.STATE_ACTIVE -> StartReceiverActivity.getIntent(this, true)
42 | Tile.STATE_INACTIVE -> StartReceiverActivity.getIntent(this, false)
43 | else -> null
44 | }
45 |
46 | intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
47 | intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
48 |
49 | if (intent != null) {
50 | if (Build.VERSION.SDK_INT >= 34) {
51 | startActivityAndCollapse(
52 | PendingIntent.getActivity(
53 | this,
54 | Random.nextInt(),
55 | intent,
56 | PendingIntent.FLAG_IMMUTABLE
57 | )
58 | )
59 | } else {
60 | startActivityAndCollapse(intent)
61 | }
62 | }
63 | }
64 |
65 | private fun setState(enabled: Boolean) {
66 | qsTile?.state = if (enabled) {
67 | Tile.STATE_ACTIVE
68 | } else {
69 | Tile.STATE_INACTIVE
70 | }
71 | qsTile?.updateTile()
72 | }
73 |
74 | override fun onStartListening() {
75 | super.onStartListening()
76 | Log.d(TAG, "onStartListening")
77 | setState(false)
78 |
79 | val r = MyReceiver(this)
80 | registerInternalBroadcastReceiver(
81 | r, IntentFilter().apply {
82 | addAction(ServiceState.ACTION_UPDATE_RECEIVER_STATE)
83 | }
84 | )
85 | receiver = r
86 |
87 | sendBroadcast(ServiceState.getQueryIntent())
88 | }
89 |
90 | override fun onStopListening() {
91 | super.onStopListening()
92 | Log.d(TAG, "onStopListening")
93 | receiver?.let {
94 | unregisterReceiver(it)
95 | }
96 | receiver = null
97 | }
98 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/ui/Cards.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.ui
2 |
3 | import androidx.compose.foundation.layout.ColumnScope
4 | import androidx.compose.foundation.layout.fillMaxWidth
5 | import androidx.compose.material3.Card
6 | import androidx.compose.material3.CardDefaults
7 | import androidx.compose.material3.MaterialTheme
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 |
11 | @Composable
12 | fun DefaultCard(
13 | modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit
14 | ) {
15 | Card(
16 | colors = CardDefaults.cardColors(
17 | containerColor = MaterialTheme.colorScheme.surfaceVariant,
18 | ), modifier = modifier.fillMaxWidth(), content = content
19 | )
20 | }
21 |
22 | @Composable
23 | fun DefaultCard(
24 | onClick: () -> Unit, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit
25 | ) {
26 | Card(
27 | onClick = onClick, colors = CardDefaults.cardColors(
28 | containerColor = MaterialTheme.colorScheme.surfaceVariant,
29 | ), modifier = modifier.fillMaxWidth(), content = content
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.ui.theme
2 |
3 | import android.os.Build
4 | import androidx.compose.foundation.isSystemInDarkTheme
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.material3.darkColorScheme
7 | import androidx.compose.material3.dynamicDarkColorScheme
8 | import androidx.compose.material3.dynamicLightColorScheme
9 | import androidx.compose.material3.lightColorScheme
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.platform.LocalContext
12 |
13 | private val DarkColorScheme = darkColorScheme(
14 | primary = Purple80,
15 | secondary = PurpleGrey80,
16 | tertiary = Pink80
17 | )
18 |
19 | private val LightColorScheme = lightColorScheme(
20 | primary = Purple40,
21 | secondary = PurpleGrey40,
22 | tertiary = Pink40
23 |
24 | /* Other default colors to override
25 | background = Color(0xFFFFFBFE),
26 | surface = Color(0xFFFFFBFE),
27 | onPrimary = Color.White,
28 | onSecondary = Color.White,
29 | onTertiary = Color.White,
30 | onBackground = Color(0xFF1C1B1F),
31 | onSurface = Color(0xFF1C1B1F),
32 | */
33 | )
34 |
35 | @Composable
36 | fun CatShareTheme(
37 | darkTheme: Boolean = isSystemInDarkTheme(),
38 | // Dynamic color is available on Android 12+
39 | dynamicColor: Boolean = true,
40 | content: @Composable () -> Unit
41 | ) {
42 | val colorScheme = when {
43 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
44 | val context = LocalContext.current
45 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
46 | }
47 |
48 | darkTheme -> DarkColorScheme
49 | else -> LightColorScheme
50 | }
51 |
52 | MaterialTheme(
53 | colorScheme = colorScheme,
54 | typography = Typography,
55 | content = content
56 | )
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/BleUtils.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | import java.nio.ByteBuffer
4 | import java.util.Arrays
5 | import java.util.Random
6 | import java.util.UUID
7 | import kotlin.math.abs
8 |
9 |
10 | object BleUtils {
11 | val ADV_SERVICE_UUID = UUID.fromString("00003331-0000-1000-8000-008123456789")
12 | val SERVICE_UUID = UUID.fromString("00009955-0000-1000-8000-00805f9b34fb")
13 | val CHAR_STATUS_UUID = UUID.fromString("00009954-0000-1000-8000-00805f9b34fb")
14 | val CHAR_P2P_UUID = UUID.fromString("00009953-0000-1000-8000-00805f9b34fb")
15 |
16 | val RANDOM_DATA: ByteArray = run {
17 | val random = Random()
18 | Arrays.copyOfRange(
19 | ByteBuffer.allocate(8).putLong(abs(random.nextLong())).array(),
20 | 0,
21 | 2
22 | )
23 | }
24 |
25 | fun getSenderId(): String {
26 | val senderIdRaw = RANDOM_DATA[0].toInt().shl(8).or(RANDOM_DATA[1].toInt())
27 | return String.format("%04x", senderIdRaw)
28 | }
29 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/CoroutineUtils.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | import androidx.annotation.StringRes
4 | import kotlinx.coroutines.CoroutineScope
5 | import kotlinx.coroutines.Deferred
6 | import kotlinx.coroutines.TimeoutCancellationException
7 | import kotlinx.coroutines.time.withTimeout
8 | import moe.reimu.catshare.exceptions.ExceptionWithMessage
9 | import java.time.Duration
10 | import java.util.concurrent.TimeoutException
11 |
12 |
13 | suspend fun withTimeoutReason(
14 | duration: Duration,
15 | reason: String,
16 | @StringRes messageId: Int,
17 | block: suspend CoroutineScope.() -> T
18 | ): T {
19 | try {
20 | return withTimeout(duration, block)
21 | } catch (e: TimeoutCancellationException) {
22 | throw ExceptionWithMessage(
23 | "Timed out after ${duration.toMillis()} ms: $reason",
24 | e,
25 | messageId
26 | )
27 | }
28 | }
29 |
30 | suspend fun Deferred.awaitWithTimeout(
31 | timeout: Duration,
32 | reason: String,
33 | @StringRes messageId: Int,
34 | ): T {
35 | return withTimeoutReason(timeout, reason, messageId) {
36 | await()
37 | }
38 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/DeviceUtils.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | import java.util.Random
4 |
5 | object DeviceUtils {
6 | private val alphabet =
7 | "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray()
8 |
9 | fun deviceNameById(id: Byte): String? {
10 | return when (id) {
11 | in 10..19 -> {
12 | if (id.toInt() == 11) {
13 | "realme"
14 | } else {
15 | "OPPO"
16 | }
17 | }
18 |
19 | in 20..29 -> {
20 | "vivo"
21 | }
22 |
23 | in 30..39 -> {
24 | "Xiaomi"
25 | }
26 |
27 | in 41..45 -> {
28 | "OnePlus"
29 | }
30 |
31 | in 50..59 -> {
32 | "Meizu"
33 | }
34 |
35 | in 70..75 -> {
36 | "Samsung"
37 | }
38 |
39 | in 100..109 -> {
40 | "Lenovo"
41 | }
42 |
43 | else -> null
44 | }
45 | }
46 |
47 | fun getRandomChars(len: Int): String {
48 | val sb = StringBuilder()
49 | val rand = Random()
50 | repeat(len) {
51 | sb.append(alphabet[rand.nextInt(alphabet.size)])
52 | }
53 | return sb.toString()
54 | }
55 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/JsonUtils.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | import kotlinx.serialization.json.Json
4 |
5 | val JsonWithUnknownKeys = Json { ignoreUnknownKeys = true }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/LogUtils.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | val Any.TAG: String
4 | get() {
5 | val tag = javaClass.simpleName
6 | return if (tag.length <= 23) tag else tag.substring(0, 23)
7 | }
8 |
9 |
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/NotificationUtils.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | import android.content.Context
4 | import android.widget.Toast
5 | import androidx.core.app.NotificationChannelCompat
6 | import androidx.core.app.NotificationManagerCompat
7 | import moe.reimu.catshare.R
8 |
9 | object NotificationUtils {
10 | const val RECEIVER_FG_CHAN_ID = "RECEIVER_FG"
11 | const val SENDER_CHAN_ID = "SENDER"
12 | const val RECEIVER_CHAN_ID = "RECEIVER"
13 | const val OTHER_CHAN_ID = "OTHER"
14 |
15 | const val GATT_SERVER_FG_ID = 1
16 | const val RECEIVER_FG_ID = 2
17 | const val SENDER_FG_ID = 3
18 |
19 | fun createChannels(context: Context) {
20 | val manager = NotificationManagerCompat.from(context)
21 |
22 | val channels = listOf(
23 | NotificationChannelCompat.Builder(
24 | RECEIVER_FG_CHAN_ID,
25 | NotificationManagerCompat.IMPORTANCE_LOW
26 | ).setName("Receiver persistent notification (can be disabled)").build(),
27 | NotificationChannelCompat.Builder(
28 | SENDER_CHAN_ID,
29 | NotificationManagerCompat.IMPORTANCE_HIGH
30 | ).setName("Sending files").build(),
31 | NotificationChannelCompat.Builder(
32 | RECEIVER_CHAN_ID,
33 | NotificationManagerCompat.IMPORTANCE_HIGH
34 | ).setName("Receiving files").build(),
35 | NotificationChannelCompat.Builder(
36 | OTHER_CHAN_ID,
37 | NotificationManagerCompat.IMPORTANCE_DEFAULT
38 | ).setName("Other notifications").build(),
39 | )
40 |
41 | manager.createNotificationChannelsCompat(channels)
42 | }
43 |
44 | fun showBusyToast(context: Context) {
45 | Toast.makeText(context, R.string.app_busy_toast, Toast.LENGTH_LONG).show()
46 | }
47 |
48 | fun showBluetoothToast(context: Context) {
49 | Toast.makeText(context, R.string.bluetooth_disabled, Toast.LENGTH_LONG).show()
50 | }
51 |
52 | fun showWifiToast(context: Context) {
53 | Toast.makeText(context, R.string.wifi_disabled, Toast.LENGTH_LONG).show()
54 | }
55 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/P2pUtils.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | import android.annotation.SuppressLint
4 | import android.net.wifi.p2p.WifiP2pConfig
5 | import android.net.wifi.p2p.WifiP2pGroup
6 | import android.net.wifi.p2p.WifiP2pManager
7 | import kotlinx.coroutines.CompletableDeferred
8 | import moe.reimu.catshare.R
9 | import moe.reimu.catshare.exceptions.ExceptionWithMessage
10 |
11 | @SuppressLint("MissingPermission")
12 | suspend fun WifiP2pManager.requestGroupInfo(channel: WifiP2pManager.Channel): WifiP2pGroup? {
13 | val groupInfoFuture = CompletableDeferred()
14 | requestGroupInfo(channel) {
15 | groupInfoFuture.complete(it)
16 | }
17 | return groupInfoFuture.await()
18 | }
19 |
20 | class P2pFutureActionListener : WifiP2pManager.ActionListener {
21 | val deferred = CompletableDeferred()
22 |
23 | override fun onSuccess() {
24 | deferred.complete(Unit)
25 | }
26 |
27 | override fun onFailure(reason: Int) {
28 | val message = when (reason) {
29 | WifiP2pManager.ERROR -> "ERROR"
30 | WifiP2pManager.P2P_UNSUPPORTED -> "P2P_UNSUPPORTED"
31 | WifiP2pManager.BUSY -> "BUSY"
32 | else -> "code $reason"
33 | }
34 | deferred.completeExceptionally(RuntimeException("WiFi P2P operation failed: $message"))
35 | }
36 | }
37 |
38 | @SuppressLint("MissingPermission")
39 | suspend fun WifiP2pManager.createGroupSuspend(
40 | channel: WifiP2pManager.Channel,
41 | config: WifiP2pConfig
42 | ) {
43 | val l = P2pFutureActionListener()
44 | createGroup(channel, config, l)
45 | try {
46 | l.deferred.await()
47 | } catch (e: Throwable) {
48 | throw ExceptionWithMessage("Failed to create P2P group", e, R.string.error_p2p_failed)
49 | }
50 | }
51 |
52 | suspend fun WifiP2pManager.removeGroupSuspend(channel: WifiP2pManager.Channel) {
53 | val l = P2pFutureActionListener()
54 | removeGroup(channel, l)
55 | try {
56 | l.deferred.await()
57 | } catch (e: Throwable) {
58 | throw ExceptionWithMessage("Failed to remove P2P group", e, R.string.error_p2p_failed)
59 | }
60 | }
61 |
62 | @SuppressLint("MissingPermission")
63 | suspend fun WifiP2pManager.connectSuspend(channel: WifiP2pManager.Channel, config: WifiP2pConfig) {
64 | val l = P2pFutureActionListener()
65 | connect(channel, config, l)
66 | try {
67 | l.deferred.await()
68 | } catch (e: Throwable) {
69 | throw ExceptionWithMessage("Failed to connect P2P", e, R.string.error_p2p_failed)
70 | }
71 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/PermissionUtils.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | import android.Manifest
4 | import android.content.BroadcastReceiver
5 | import android.content.Context
6 | import android.content.IntentFilter
7 | import android.content.pm.PackageManager
8 | import android.os.Build
9 | import androidx.core.content.ContextCompat
10 | import moe.reimu.catshare.BuildConfig
11 |
12 | val INTERNAL_BROADCAST_PERMISSION = "${BuildConfig.APPLICATION_ID}.INTERNAL_BROADCASTS"
13 |
14 | fun Context.checkBluetoothPermissions(): Boolean {
15 | if (Build.VERSION.SDK_INT <= 32) {
16 | if (ContextCompat.checkSelfPermission(
17 | this, Manifest.permission.ACCESS_FINE_LOCATION
18 | ) != PackageManager.PERMISSION_GRANTED
19 | ) {
20 | return false
21 | }
22 | }
23 |
24 | if (Build.VERSION.SDK_INT >= 31) {
25 | for (perm in listOf(
26 | Manifest.permission.BLUETOOTH_ADVERTISE,
27 | Manifest.permission.BLUETOOTH_SCAN,
28 | Manifest.permission.BLUETOOTH_CONNECT
29 | )) {
30 | if (ContextCompat.checkSelfPermission(
31 | this,
32 | perm
33 | ) != PackageManager.PERMISSION_GRANTED
34 | ) {
35 | return false
36 | }
37 | }
38 | }
39 |
40 | return true
41 | }
42 |
43 | fun Context.checkP2pPermissions(): Boolean {
44 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(
45 | this, Manifest.permission.NEARBY_WIFI_DEVICES
46 | ) != PackageManager.PERMISSION_GRANTED
47 | ) {
48 | return false
49 | }
50 |
51 | if (Build.VERSION.SDK_INT <= 32) {
52 | if (ContextCompat.checkSelfPermission(
53 | this, Manifest.permission.ACCESS_FINE_LOCATION
54 | ) != PackageManager.PERMISSION_GRANTED
55 | ) {
56 | return false
57 | }
58 | }
59 |
60 | return true
61 | }
62 |
63 | fun Context.registerInternalBroadcastReceiver(receiver: BroadcastReceiver, filter: IntentFilter) {
64 | registerReceiver(receiver, filter, INTERNAL_BROADCAST_PERMISSION, null, getReceiverFlags())
65 | }
66 |
67 | fun getReceiverFlags(): Int {
68 | return if (Build.VERSION.SDK_INT >= 33) {
69 | Context.RECEIVER_EXPORTED
70 | } else {
71 | 0
72 | }
73 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/ProgressCounter.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | import java.util.concurrent.TimeUnit
4 |
5 | class ProgressCounter(private val totalSize: Long, private val callback: (Long, Long) -> Unit) {
6 | private var lastProgressUpdate = 0L
7 |
8 | fun update(processedSize: Long) {
9 | val now = System.nanoTime()
10 | val elapsed = TimeUnit.SECONDS.convert(
11 | now - lastProgressUpdate, TimeUnit.NANOSECONDS
12 | )
13 | if (elapsed > 1) {
14 | callback(totalSize, processedSize)
15 | lastProgressUpdate = now
16 | }
17 | }
18 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/ServiceState.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | import android.content.Intent
4 | import moe.reimu.catshare.BuildConfig
5 |
6 | object ServiceState {
7 | const val ACTION_QUERY_RECEIVER_STATE = "${BuildConfig.APPLICATION_ID}.QUERY_RECEIVER_STATE"
8 | const val ACTION_UPDATE_RECEIVER_STATE = "${BuildConfig.APPLICATION_ID}.UPDATE_RECEIVER_STATE"
9 | const val ACTION_STOP_SERVICE = "${BuildConfig.APPLICATION_ID}.STOP_SERVICE"
10 |
11 | fun getQueryIntent() = Intent(ACTION_QUERY_RECEIVER_STATE)
12 | fun getUpdateIntent(isRunning: Boolean) = Intent(ACTION_UPDATE_RECEIVER_STATE).apply {
13 | putExtra("isRunning", isRunning)
14 | }
15 |
16 | fun getStopIntent() = Intent(ACTION_STOP_SERVICE)
17 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/ShizukuUtils.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | import android.content.ComponentName
4 | import android.content.Context
5 | import android.content.ServiceConnection
6 | import android.content.pm.PackageManager
7 | import android.os.IBinder
8 | import android.util.Log
9 | import kotlinx.coroutines.CompletableDeferred
10 | import moe.reimu.catshare.BuildConfig
11 | import moe.reimu.catshare.IMacAddressService
12 | import moe.reimu.catshare.services.MacAddressService
13 | import rikka.shizuku.Shizuku
14 | import java.net.NetworkInterface
15 | import kotlin.collections.iterator
16 |
17 | object ShizukuUtils {
18 | private val binderLock = Object()
19 | private val serviceNotify = Object()
20 | private var macService: IMacAddressService? = null
21 |
22 | init {
23 | Shizuku.addBinderReceivedListenerSticky {
24 | bindService()
25 | }
26 | }
27 |
28 | private val serviceConnection = object : ServiceConnection {
29 | override fun onServiceConnected(name: ComponentName, service: IBinder?) {
30 | if (service != null && service.pingBinder()) {
31 | Log.d(ShizukuUtils.TAG, "Got service connection for $name")
32 |
33 | synchronized(binderLock) {
34 | macService = IMacAddressService.Stub.asInterface(service)
35 | }
36 |
37 | synchronized(serviceNotify) {
38 | serviceNotify.notifyAll()
39 | }
40 | }
41 | }
42 |
43 | override fun onServiceDisconnected(name: ComponentName) {
44 | Log.d(ShizukuUtils.TAG, "Connection lost for $name")
45 | synchronized(binderLock) {
46 | macService = null
47 | }
48 | }
49 | }
50 |
51 | fun unsafeBindService() {
52 | val cn = ComponentName(
53 | BuildConfig.APPLICATION_ID, MacAddressService::class.java.name
54 | )
55 | val args = Shizuku.UserServiceArgs(cn)
56 | .daemon(false)
57 | .processNameSuffix("service")
58 | .debuggable(BuildConfig.DEBUG)
59 | .version(BuildConfig.VERSION_CODE)
60 | Shizuku.bindUserService(args, serviceConnection)
61 | }
62 |
63 | fun bindService() {
64 | try {
65 | unsafeBindService()
66 | } catch (e: Throwable) {
67 | Log.e(TAG, "Failed to bind service", e)
68 | }
69 | }
70 |
71 | fun unsafeGetMacAddress(name: String): String? {
72 | synchronized(binderLock) {
73 | val svc = macService
74 | if (svc == null) {
75 | Log.d(TAG, "MAC service is null, trying to bind")
76 | unsafeBindService()
77 | } else {
78 | return svc.getMacAddressByName(name)
79 | }
80 | }
81 |
82 | synchronized(serviceNotify) {
83 | serviceNotify.wait(1000 * 20)
84 | }
85 |
86 | synchronized(binderLock) {
87 | return macService?.p2pMacAddress
88 | }
89 | }
90 |
91 | fun getMacAddress(context: Context, name: String, l: (String?) -> Unit) {
92 | if (context.checkSelfPermission("android.permission.LOCAL_MAC_ADDRESS") == PackageManager.PERMISSION_GRANTED) {
93 | Log.d(TAG, "Permission granted, using native method")
94 | l(nativeGetMacAddressByName(name))
95 | return
96 | }
97 |
98 | val th = Thread {
99 | val res = try {
100 | unsafeGetMacAddress(name)
101 | } catch (e: Throwable) {
102 | Log.e(ShizukuUtils.TAG, "Failed to obtain MAC address for $name", e)
103 | null
104 | }
105 |
106 | l(res)
107 | }
108 | th.start()
109 | }
110 |
111 | @OptIn(ExperimentalStdlibApi::class)
112 | private fun nativeGetMacAddressByName(name: String): String? {
113 | val ifs = NetworkInterface.getNetworkInterfaces()
114 | for (intf in ifs) {
115 | if (intf.name == name) {
116 | return intf.hardwareAddress?.toHexString(HexFormat {
117 | bytes.byteSeparator = ":"
118 | })
119 | }
120 | }
121 | return null
122 | }
123 |
124 | suspend fun getMacAddress(context: Context, name: String): String? {
125 | val fut = CompletableDeferred()
126 | getMacAddress(context, name) {
127 | fut.complete(it)
128 | }
129 | return fut.await()
130 | }
131 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/WsUtils.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | import io.ktor.websocket.Frame
4 | import io.ktor.websocket.WebSocketSession
5 | import moe.reimu.catshare.models.WebSocketMessage
6 |
7 | suspend fun WebSocketSession.sendStatus(id: Int, taskId: String, type: Int, reason: String) {
8 | val st = WebSocketMessage.makeStatus(id, taskId, type, reason)
9 | send(Frame.Text(st.toText()))
10 | flush()
11 | }
12 |
13 | suspend fun WebSocketSession.sendStatusIgnoreException(id: Int, taskId: String, type: Int, reason: String) {
14 | try {
15 | sendStatus(id, taskId, type, reason)
16 | } catch (e: Throwable) {
17 | e.printStackTrace()
18 | }
19 | }
--------------------------------------------------------------------------------
/app/src/main/java/moe/reimu/catshare/utils/ZipPathValidatorCallback.kt:
--------------------------------------------------------------------------------
1 | package moe.reimu.catshare.utils
2 |
3 | import android.os.Build
4 | import android.util.Log
5 | import androidx.annotation.RequiresApi
6 | import dalvik.system.ZipPathValidator.Callback
7 |
8 | @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
9 | object ZipPathValidatorCallback : Callback {
10 | override fun onZipEntryAccess(path: String) {
11 | Log.d(TAG, path)
12 | super.onZipEntryAccess(path)
13 | }
14 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_bluetooth_searching.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_close.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_done.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_download.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_downloading.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
13 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_monochrome.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_share.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_upload_file.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_warning.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/values-zh-rCN/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 发送
4 | 可见
5 | 其他用户可向您发送文件
6 | 选择文件或照片
7 | 此功能尚未完成开发
8 | 正在连接至对端设备
9 | 正在等待文件信息
10 |
11 | - 请求发送 %1$d 个文件 - %2$s
12 |
13 | 准备文件传输
14 | 接受
15 | 拒绝
16 | 文件接收已中断
17 | 正在准备
18 |
19 | - 接收到 %d 个文件,点击查看
20 |
21 |
22 | - 传输中断,接收到 %d 个文件,点击查看
23 |
24 | 停止
25 | CatShare 接收服务
26 | 蓝牙已关闭或不可用
27 | 正在接收
28 | 接收完成
29 | 接收中断
30 | 接收服务已启动
31 | 接收服务已停止
32 | Shizuku 可用
33 | Shizuku 不可用
34 | 本应用使用 Shizuku 获取您设备的 MAC 地址
35 | Shizuku 未授权
36 | 发送文件
37 | 正在发送
38 | 发送中断
39 | 文件发送已中断
40 | 发送完成
41 | 正在进行其他发送/接收任务
42 | 正在扫描接收者,请对方开启互传功能。
43 | 选择接收者
44 | 分享内容为空或无法读取
45 | 未知
46 | 选择文件
47 | 设置
48 | 设备名称
49 | 我的手机
50 | 日志获取失败
51 | 捕获日志
52 | 捕获调试日志以排查问题
53 | 展开此通知以查看详细信息
54 | 显示详细错误信息
55 | 在发送或接受失败时显示详细错误信息,用于排查问题
56 | 点对点连接建立失败
57 | 蓝牙通信失败
58 | 等待发送请求超时
59 | WebSocket 连接超时
60 | 发送握手超时
61 | 请求发送文本
62 | 文本已复制至剪贴板
63 | 传输已被本设备取消
64 | 传输已被对端设备取消
65 | WiFi 已关闭或不可用
66 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #684C77
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | CatShare
3 | Send
4 | Discoverable
5 | Others may send files to you
6 | Choose files and photos
7 | This feature is under development
8 | Connecting to remote device
9 | Waiting for file information
10 |
11 | - Wants to send %1$d file - %2$s
12 | - Wants to send %1$d files - %2$s
13 |
14 | Preparing transmission
15 | Accept
16 | Reject
17 | Receiving process has been interrupted
18 | Preparing
19 |
20 | - Received %d file, tap to view
21 | - Received %d files, tap to view
22 |
23 |
24 | - Received %d file before interruption, tap to view
25 | - Received %d files before interruption, tap to view
26 |
27 | Stop
28 | CatShare Receiver Service
29 | Bluetooth is disabled or unavailable
30 | Receiving
31 | Received completed
32 | Receiving interrupted
33 | Receiver started
34 | Receiver stopped
35 | Shizuku is available
36 | Shizuku unavailable
37 | Shizuku is used to obtain your device\'s MAC address
38 | Shizuku permission not granted
39 | Send Files
40 | Sending
41 | Sending interrupted
42 | Sending process has been interrupted
43 | Sending completed
44 | Executing other sending/receiving tasks
45 | Scanning for recipients
46 | Choose recipients
47 | No file shared or failed to read contents
48 | Unknown
49 | Choose Files
50 | Settings
51 | Device Name
52 | My Phone
53 | Failed to capture logs
54 | Capture Logs
55 | Capture debug logs for troubleshooting
56 | Expand this notification for details
57 | Show detailed errors
58 | Show detailed error messages when sending or receiving fails, for troubleshooting
59 | Failed to create WiFi P2P connection
60 | Bluetooth communication failed
61 | Timed out waiting for request
62 | Timeout waiting for WebSocket connection
63 | Timeout waiting for handshake
64 | Wants to share text
65 | Text copied to clipboard
66 | Transmission has been cancalled by this device
67 | Transmission has been cancelled by remote device
68 | WiFi is disabled or unavailable
69 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/provider_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | alias(libs.plugins.android.application) apply false
4 | alias(libs.plugins.kotlin.android) apply false
5 | alias(libs.plugins.kotlin.compose) apply false
6 | alias(libs.plugins.kotlin.serialization) apply false
7 | alias(libs.plugins.kotlin.parcelize) apply false
8 | alias(libs.plugins.jetbrains.kotlin.jvm) apply false
9 | alias(libs.plugins.android.library) apply false
10 | }
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/full_description.txt:
--------------------------------------------------------------------------------
1 | Features:
2 |
3 | - Bluetooth discovery
4 | - Receive file
5 | - Send file (Shizuku required)
6 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/icon.png:
--------------------------------------------------------------------------------
1 | ../../../../../app/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/short_description.txt:
--------------------------------------------------------------------------------
1 | App for Mutual Transmission Alliance
2 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/full_description.txt:
--------------------------------------------------------------------------------
1 | 功能:
2 |
3 | - 蓝牙发现
4 | - 文件接收
5 | - 文件发送(需要 Shizuku 支持)
6 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/short_description.txt:
--------------------------------------------------------------------------------
1 | 互传联盟应用
2 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.7.3"
3 | kotlin = "2.0.0"
4 | coreKtx = "1.15.0"
5 | lifecycleRuntimeKtx = "2.8.7"
6 | activityCompose = "1.9.3"
7 | composeBom = "2024.12.01"
8 | ktor = "3.0.3"
9 | appcompat = "1.7.0"
10 | material = "1.12.0"
11 | activity = "1.9.3"
12 | shizuku = "13.1.5"
13 | jetbrainsKotlinJvm = "2.0.0"
14 |
15 | [libraries]
16 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
17 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
18 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
19 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
20 | androidx-ui = { group = "androidx.compose.ui", name = "ui" }
21 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
22 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
23 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
24 |
25 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
26 | ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
27 | ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
28 | ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
29 | ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
30 | ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
31 | ktor-network-tls-certificates = { module = "io.ktor:ktor-network-tls-certificates", version.ref = "ktor" }
32 |
33 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.7.3" }
34 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
35 | material = { group = "com.google.android.material", name = "material", version.ref = "material" }
36 | androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
37 |
38 | shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" }
39 | shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" }
40 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
41 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
42 |
43 | [plugins]
44 | android-application = { id = "com.android.application", version.ref = "agp" }
45 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
46 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
47 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
48 | kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
49 | jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }
50 | android-library = { id = "com.android.library", version.ref = "agp" }
51 |
52 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kmod-midori/CatShare/900d16eeb4e127e62c566427c44dd531c6913ea4/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sun Dec 29 17:01:12 CST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | }
20 | }
21 |
22 | rootProject.name = "CatShare"
23 | include(":app")
24 | include(":apistub")
25 |
--------------------------------------------------------------------------------