├── .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 | [Get it on F-Droid](https://f-droid.org/packages/moe.reimu.catshare) 11 | [Get it on OpenAPK](https://www.openapk.net/catshare/moe.reimu.catshare/) 14 | [Get it on Android Freeware](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 | 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 | --------------------------------------------------------------------------------