2
0
mirror of https://github.com/KDE/kdeconnect-android synced 2025-09-02 07:05:09 +00:00

Compare commits

...

89 Commits

Author SHA1 Message Date
Albert Vaca Cintora
6c8d22b1ed Release 1.33.2 2025-03-11 17:17:44 +01:00
Albert Vaca Cintora
69adfbfbc2 Do the kdeconnect handshake in a new thread
Some Android versions seem to hang if calling sslSocket.getOutputStream()
from within the HandshakeCompleted callback (maybe because calling it in
on a socket that hasn't finished the SSL handshake is supposed to trigger
the SSL handshake).

BUG: 501241
2025-03-11 17:14:36 +01:00
Albert Vaca Cintora
f80e29538a Do not use BufferedReader to read from socket
Reading the docs, BufferedReader maybe could read and cache more than one
line from the socket, and since we discarded the BufferedReader and
created a new one (up to three times), data could be lost.
2025-03-11 12:44:33 +01:00
l10n daemon script
56dda889d1 GIT_SILENT made messages (after extraction) 2025-03-08 02:05:24 +00:00
Albert Vaca Cintora
0c48f388f4 Release 1.33.1 2025-03-05 15:53:13 +01:00
Albert Vaca Cintora
3eda9d4ef9 Allow hyphens in device ids 2025-03-05 15:47:14 +01:00
Albert Vaca Cintora
5aa6fae03b Bump gradle 2025-03-05 15:47:02 +01:00
Albert Vaca Cintora
228a504b90 Bump deps 2025-03-03 14:42:43 +01:00
Albert Vaca Cintora
34c2c311e2 Fix NPE 2025-03-03 14:41:30 +01:00
l10n daemon script
08fcfc863a GIT_SILENT made messages (after extraction) 2025-03-03 01:58:29 +00:00
l10n daemon script
c550ef4445 GIT_SILENT made messages (after extraction) 2025-03-01 02:08:19 +00:00
l10n daemon script
ec40994d4c GIT_SILENT made messages (after extraction) 2025-02-27 01:53:23 +00:00
Albert Vaca Cintora
43d4f38765 Fix deprecation warning 2025-02-26 21:32:39 +01:00
Albert Vaca Cintora
fbff23a8c0 Release 1.30.0 2025-02-26 21:22:17 +01:00
Albert Vaca Cintora
52ff931c4e Fix build 2025-02-26 21:22:04 +01:00
José Rebelo
0f628d4927 Allow filtering notifications from work profile
BUG: 412188
BUG: 422445
2025-02-26 20:08:50 +00:00
Albert Vaca Cintora
737c333a91 Bump deps 2025-02-26 18:21:58 +01:00
Albert Vaca Cintora
e9e406de88 Deprecate packet id field 2025-02-26 11:10:06 +00:00
l10n daemon script
9b2e4bcf56 GIT_SILENT made messages (after extraction) 2025-02-26 01:56:17 +00:00
l10n daemon script
d3daf20c27 GIT_SILENT made messages (after extraction) 2025-02-25 01:55:19 +00:00
l10n daemon script
7de2817274 GIT_SILENT made messages (after extraction) 2025-02-24 10:45:50 +00:00
Albert Vaca Cintora
6121fa04bc Do not disconnect when there's a version mismatch
Just trust the encrypted packet.
2025-02-23 15:50:11 +00:00
Albert Vaca Cintora
bec807fa63 Catch JSONException when unserializing 2025-02-23 15:50:11 +00:00
Albert Vaca Cintora
fe97750e9a Enforce the protocol doesn't change after the handshake 2025-02-23 15:50:11 +00:00
Albert Vaca Cintora
f8a2d2da03 Fix protocol version comparison 2025-02-23 15:50:11 +00:00
Albert Vaca Cintora
68a0b73e9c Do not unpair automatically on error, just ignore the connection 2025-02-23 15:50:11 +00:00
Albert Vaca Cintora
7a4fb8b584 Make the verification key change every time in protocol v8 2025-02-23 15:50:11 +00:00
Albert Vaca Cintora
b4ee6e30b1 Also compare protocol version to consider device info needs saving 2025-02-23 15:50:11 +00:00
Albert Vaca Cintora
0560071cfb Show protocol version in encryption info 2025-02-23 15:50:11 +00:00
Albert Vaca Cintora
4343ad7e01 Do not allow protocol downgrades 2025-02-23 15:50:11 +00:00
Albert Vaca Cintora
0738710747 Remove support for protocol version < 7 2025-02-23 15:50:11 +00:00
Albert Vaca Cintora
9af8fe791b Protocol version 8: send identity packets encrypted 2025-02-23 15:50:11 +00:00
TPJ Schikhof
a9a99ea7bd Add PeerTube support to formatUriWithSeek function 2025-02-16 21:43:14 +00:00
l10n daemon script
03c2121d57 GIT_SILENT made messages (after extraction) 2025-02-15 02:15:26 +00:00
l10n daemon script
c0c38aab9a GIT_SILENT Sync po/docbooks with svn 2025-02-10 02:18:43 +00:00
l10n daemon script
ab4a6a300b GIT_SILENT Add new file (after extraction) 2025-02-10 01:51:47 +00:00
l10n daemon script
82c434273d GIT_SILENT made messages (after extraction) 2025-02-01 01:54:30 +00:00
Albert Vaca Cintora
0e1842964f Use anonymous functions instead of the callback interface 2025-01-28 00:59:47 +01:00
Albert Vaca Cintora
0a82d303e4 More fixing NPEs and improving nullability handling 2025-01-28 00:51:48 +01:00
Albert Vaca Cintora
c275e26e00 Fix NPEs and improve handling of nullability 2025-01-28 00:22:43 +01:00
Albert Vaca Cintora
d951e3faad Fix NPE 2025-01-25 10:58:56 +01:00
l10n daemon script
144d292948 GIT_SILENT made messages (after extraction) 2025-01-25 01:57:33 +00:00
Albert Vaca Cintora
32d293eb8d Fix "View not attached to window" crash 2025-01-23 12:46:29 +01:00
Albert Vaca Cintora
3e2c077674 Fix NPE crash 2025-01-23 11:58:08 +01:00
Albert Vaca Cintora
49d36d57a6 Update comments 2025-01-19 00:44:07 +01:00
Albert Vaca Cintora
8b6d789c02 Suppress linter issues 2025-01-19 00:38:44 +01:00
Albert Vaca Cintora
de73362624 Remove outdated comment 2025-01-19 00:38:44 +01:00
Albert Vaca Cintora
7bc90fbe85 Simplify regex 2025-01-18 20:42:27 +00:00
Albert Vaca Cintora
818b99774d Add test 2025-01-18 20:42:27 +00:00
Albert Vaca Cintora
13b09ffae8 Enforce format of device IDs 2025-01-18 20:42:27 +00:00
Albert Vaca Cintora
56c96b686d Last round of comment format fixes 2025-01-18 14:58:56 +01:00
Albert Vaca Cintora
3b4f5f83b2 More comment format fixes 2025-01-18 14:55:51 +01:00
TPJ Schikhof
fc18d8a10f Improve SMSPlugin doc comment formatting 2025-01-18 13:51:26 +00:00
Albert Vaca Cintora
d73236ab96 Gitignore files created during tests because of a bug
https://bugs.openjdk.org/browse/JDK-8214300
2025-01-18 14:46:12 +01:00
Albert Vaca Cintora
ecd4bec109 Release 1.32.11 2025-01-18 11:42:02 +01:00
l10n daemon script
bb152c4900 GIT_SILENT made messages (after extraction) 2025-01-15 01:53:11 +00:00
TPJ Schikhof
49a9cd5ea7 Refactoring 2025-01-13 22:35:29 +00:00
TPJ Schikhof
e363a5875a Migrate SMSPlugin to Kotlin 2025-01-13 22:35:29 +00:00
TPJ Schikhof
55d3fd630c Rename .java to .kt 2025-01-13 22:35:29 +00:00
l10n daemon script
a85e6f8057 GIT_SILENT made messages (after extraction) 2025-01-13 01:57:08 +00:00
Albert Vaca Cintora
125c9d54b3 Release 1.32.10 2025-01-12 21:42:05 +01:00
Albert Vaca Cintora
5b937313ff Bump dependencies 2025-01-12 21:34:52 +01:00
l10n daemon script
2ad9f8eeb1 GIT_SILENT made messages (after extraction) 2025-01-12 02:01:35 +00:00
l10n daemon script
b3d84f31f4 GIT_SILENT made messages (after extraction) 2025-01-05 01:55:07 +00:00
l10n daemon script
5ca96fc378 GIT_SILENT made messages (after extraction) 2025-01-04 01:51:55 +00:00
l10n daemon script
05f1cbe136 GIT_SILENT made messages (after extraction) 2025-01-02 01:52:10 +00:00
l10n daemon script
d02e5aabb5 GIT_SILENT made messages (after extraction) 2025-01-01 01:52:54 +00:00
Mash Kyrielight
ae24cd6ca8 Fix insets for android 15
Apps are edge-to-edge by default on devices running Android 15
https://developer.android.com/develop/ui/views/layout/edge-to-edge

Fix: https://bugs.kde.org/show_bug.cgi?id=495999
2024-12-31 18:00:39 +00:00
Albert Vaca Cintora
16414401c0 Honor trusted network for MDNS discovery 2024-12-31 12:58:37 +00:00
Albert Vaca Cintora
3f120fbea8 Move version to build.gradle.kts 2024-12-31 09:24:17 +01:00
l10n daemon script
97806cf6b0 GIT_SILENT made messages (after extraction) 2024-12-31 01:54:17 +00:00
l10n daemon script
a923deee58 GIT_SILENT made messages (after extraction) 2024-12-30 02:01:30 +00:00
Albert Vaca Cintora
0923c8ecda Show number of custom devices in settings fragment 2024-12-29 15:19:22 +00:00
TPJ Schikhof
172822239c Migrate VolumeKeyListener to Kotlin 2024-12-29 10:54:01 +00:00
Albert Vaca Cintora
6a58cc444e Remove old proguard rule 2024-12-29 02:50:57 +01:00
Albert Vaca Cintora
26667e4b78 Simplify proguard-rules 2024-12-29 02:44:12 +01:00
Albert Vaca Cintora
086d366a1c Fix authority not matching manifest in debug builds 2024-12-29 02:34:10 +01:00
Albert Vaca Cintora
84d380aee5 Simplify build.gradle 2024-12-29 02:26:10 +01:00
Albert Vaca Cintora
1ea956f5fb Add en-GB strings for debug (my phone is in GB English) 2024-12-29 02:25:34 +01:00
Albert Vaca Cintora
cfc7242db5 Move "debug" before the name so it's visible in the launcher 2024-12-29 02:25:11 +01:00
l10n daemon script
93b257d46c GIT_SILENT made messages (after extraction) 2024-12-28 02:06:03 +00:00
TPJ Schikhof
fa22722498 Allow for debugging on the same device
This change builds separate debugging app with a different
ID which can be installed alongside the release version.
2024-12-27 18:20:08 +00:00
l10n daemon script
b0c9e46a31 GIT_SILENT made messages (after extraction) 2024-12-27 01:57:15 +00:00
l10n daemon script
53b49163d5 GIT_SILENT made messages (after extraction) 2024-12-25 02:20:51 +00:00
Albert Vaca Cintora
444f5725af Bump deps 2024-12-24 17:35:37 +01:00
TPJ Schikhof
1104baca8f Remove unused SmallEntryItem.kt 2024-12-24 13:00:13 +00:00
Albert Vaca Cintora
c3af9b03f6 Replace classindex with my own implementation that uses KSP instead of KAPT 2024-12-24 12:40:51 +00:00
l10n daemon script
ecb38f2518 GIT_SILENT made messages (after extraction) 2024-12-24 01:52:21 +00:00
TPJ Schikhof
75ddac0bf0 Reworked custom device list
- Solved serialization issue when commas were used
- Validate hosts and show toast message if host is invalid
- Show whether device can be reached over the network
- Show toast message when host already exists
- Code TODO's (including sorting device list)
2024-12-22 19:41:58 +00:00
108 changed files with 2760 additions and 1911 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.attach_pid*
local.properties
/.gradle/
/.idea/

View File

@@ -7,9 +7,7 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:versionCode="13209"
android:versionName="1.32.9">
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.telephony"
@@ -359,7 +357,7 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="org.kde.kdeconnect_tp.fileprovider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data

View File

@@ -21,7 +21,7 @@ buildscript {
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.ksp)
alias(libs.plugins.dependencyLicenseReport)
alias(libs.plugins.compose.compiler)
}
@@ -50,8 +50,11 @@ android {
namespace = "org.kde.kdeconnect_tp"
compileSdk = 35
defaultConfig {
applicationId = "org.kde.kdeconnect_tp"
minSdk = 21
targetSdk = 35
versionCode = 13302
versionName = "1.33.2"
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
}
buildFeatures {
@@ -74,16 +77,18 @@ android {
androidResources {
generateLocaleConfig = true
}
sourceSets {
getByName("main") {
manifest.srcFile("AndroidManifest.xml")
java.setSrcDirs(listOf("src"))
resources.setSrcDirs(listOf("resources"))
res.setSrcDirs(listOf(licenseResDir, "res"))
assets.setSrcDirs(listOf("assets"))
setRoot(".") // By default AGP expects all directories under src/main/...
java.srcDir("src") // by default is "java"
res.setSrcDirs(listOf(licenseResDir, "res")) // add licenseResDir
}
getByName("debug") {
res.srcDir("dbg-res")
}
getByName("test") {
java.setSrcDirs(listOf("tests"))
java.srcDir("tests")
}
}
@@ -105,6 +110,8 @@ android {
isMinifyEnabled = false
isShrinkResources = false
signingConfig = signingConfigs.getByName("debug")
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
getByName("release") {
isMinifyEnabled = true
@@ -254,6 +261,10 @@ abstract class FixCollectionsClassVisitorFactory :
interface Params : InstrumentationParameters
}
ksp {
arg("com.albertvaka.classindexksp.annotations", "org.kde.kdeconnect.Plugins.PluginFactory.LoadablePlugin")
}
androidComponents {
onVariants { variant ->
variant.instrumentation.transformClassesWith(
@@ -304,11 +315,9 @@ dependencies {
implementation(libs.apache.sshd.mina)
implementation(libs.apache.mina.core)
//implementation("com.github.bright:slf4android:0.1.6") { transitive = true } // For org.apache.sshd debugging
implementation(libs.bcpkix.jdk15on) //For SSL certificate generation
implementation(libs.classindex)
kapt(libs.classindex)
ksp(libs.classindexksp)
// The android-smsmms library is the only way I know to handle MMS in Android
// (Shouldn't a phone OS make phone things easy?)

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<!-- <background android:drawable="@drawable/ic_launcher_background"/>-->
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<!-- <background android:drawable="@drawable/ic_launcher_banner_background"/>-->
<foreground android:drawable="@drawable/ic_launcher_banner_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<!-- <background android:drawable="@drawable/ic_launcher_background"/>-->
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="kde_connect">Debug KDE Connect</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="kde_connect">Debug KDE Connect</string>
</resources>

View File

@@ -0,0 +1,21 @@
يوفر كِيدِي المتّصل مجموعة من الميزات لدمج سير عملك عبر الأجهزة:
- نقل الملفات بين أجهزتك.
- الوصول إلى الملفات الموجودة على هاتفك من جهاز الكمبيوتر الخاص بك، دون أسلاك.
- الحافظة المشتركة: النسخ واللصق بين أجهزتك.
- الحصول على إشعارات للمكالمات والرسائل الواردة على جهاز الكمبيوتر الخاص بك.
- لوحة اللمس الافتراضية: استخدم شاشة هاتفك كلوحة لمس لجهاز الكمبيوتر الخاص بك.
- مزامنة الإشعارات: الوصول إلى إشعارات هاتفك من جهاز الكمبيوتر الخاص بك والرد على الرسائل.
- التحكم عن بعد في الوسائط المتعددة: استخدم هاتفك كجهاز تحكم عن بعد لمشغلات الوسائط لينكس.
- اتصال WiFi: لا حاجة إلى سلك USB أو بلوتوث.
- تشفير TLS من البداية إلى النهاية: معلوماتك آمنة.
يرجى ملاحظة أنك ستحتاج إلى تثبيت كِيدِي المتّصل على حاسوبك حتى يعمل هذا التطبيق، والحفاظ على تحديث إصدار سطح المكتب بإصدار أندوريد حتى تعمل أحدث الميزات.
معلومات الأذونات الحساسة:
* إذن إمكانية الوصول: مطلوب لتلقي إدخال من جهاز آخر للتحكم في هاتف أندرويد خاص بك، إذا كنت تستخدم ميزة الإدخال عن بُعد.
* إذن تحديد الموقع في الخلفية: مطلوب لمعرفة شبكة واي فاي التي تتصل بها، إذا كنت تستخدم ميزة الشبكات الموثوقة.
لا يرسل كِيدِي المتّصل أي معلومات إلى كيدي أو إلى أي طرف ثالث. يرسل كِيدِي المتّصل البيانات من جهاز إلى آخر مباشرةً باستخدام الشبكة المحلية، وليس عبر الإنترنت، وباستخدام التشفير من البداية إلى النهاية.
هذا التطبيق جزء من مشروع مفتوح المصدر وهو موجود بفضل جميع الأشخاص الذين ساهموا فيه. قم بزيارة الموقع الإلكتروني للحصول على الكود المصدر.

View File

@@ -0,0 +1 @@
يقوم كِيدِي المتّصل بدمج هاتفك الذكي والحاسوب

View File

@@ -0,0 +1 @@
KDE Connect

View File

@@ -0,0 +1,14 @@
1.32.10
* Fixed app showing behind the notifications bar in Android 15
* Fixed file transfers showing as failed when they succeeded
* Fixed plugin list not updating after granting permissions
1.32.3
* Fix trusted devices list
1.32.2
* Handle expired certificates
* Support doubletap drag in remote mouse
1.32.1
* Fixed a crash when opening the presentation remote

View File

@@ -0,0 +1,6 @@
1.33.0
* Add support for PeerTube links
* Allow filtering notifications from work profile
* Fix bug where devices would unpair without user interaction
* Verification key now changes every second (only if both devices support it)
* Fix crashes

View File

@@ -0,0 +1,9 @@
1.33.1
* Fix compatibility with GSConnect
1.33.0
* Add support for PeerTube links
* Allow filtering notifications from work profile
* Fix bug where devices would unpair without user interaction
* Verification key now changes every second (only if both devices support it)
* Fix crashes

View File

@@ -0,0 +1,12 @@
1.33.2
* Fix connection issues on some devices
1.33.1
* Fix compatibility with GSConnect
1.33.0
* Add support for PeerTube links
* Allow filtering notifications from work profile
* Fix bug where devices would unpair without user interaction
* Verification key now changes every second (only if both devices support it)
* Fix crashes

View File

@@ -1,15 +1,15 @@
[versions]
activityCompose = "1.9.3"
androidDesugarJdkLibs = "2.1.3"
androidGradlePlugin = "8.7.3"
activityCompose = "1.10.1"
androidDesugarJdkLibs = "2.1.5"
androidGradlePlugin = "8.9.0"
androidSmsmms = "kdeconnect-1-21-0"
appcompat = "1.7.0"
bcpkixJdk15on = "1.70"
classindex = "3.13"
classindexksp = "1.1"
commonsCollections4 = "4.4"
commonsIo = "2.17.0"
commonsIo = "2.18.0"
commonsLang3 = "3.17.0"
constraintlayoutCompose = "1.1.0"
constraintlayoutCompose = "1.1.1"
coreKtx = "1.15.0"
dependencyLicenseReport = "2.7"
disklrucache = "2.0.2"
@@ -17,24 +17,25 @@ documentfile = "1.0.1"
gridlayout = "1.0.0"
jsonassert = "1.5.3"
junit = "4.13.2"
kotlin = "2.0.21"
kotlinxCoroutinesCore = "1.9.0"
kotlin = "2.1.10"
kspPlugin = "2.1.10-1.0.30"
kotlinxCoroutinesCore = "1.10.1"
lifecycleExtensions = "2.2.0"
lifecycleRuntimeKtx = "2.8.7"
logger = "1.0.3"
material = "1.12.0"
material3 = "1.3.1"
media = "1.7.0"
minaCore = "2.2.3"
mockitoCore = "5.14.2"
minaCore = "2.2.4"
mockitoCore = "5.16.0"
preferenceKtx = "1.2.1"
reactiveStreams = "1.0.4"
recyclerview = "1.3.2"
recyclerview = "1.4.0"
rxjava = "2.2.21"
sl4j = "2.0.13"
sshdCore = "2.14.0"
sshdCore = "2.15.0"
swiperefreshlayout = "1.1.0"
uiToolingPreview = "1.7.5"
uiToolingPreview = "1.7.8"
univocityParsers = "2.9.1"
[libraries]
@@ -58,7 +59,7 @@ androidx-preference-ktx = { module = "androidx.preference:preference-ktx", versi
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" }
bcpkix-jdk15on = { module = "org.bouncycastle:bcpkix-jdk15on", version.ref = "bcpkixJdk15on" }
classindex = { module = "org.atteo.classindex:classindex", version.ref = "classindex" }
classindexksp = { module = "com.github.albertvaka:classindexksp", version.ref = "classindexksp" }
commons-collections4 = { module = "org.apache.commons:commons-collections4", version.ref = "commonsCollections4" }
commons-io = { module = "commons-io:commons-io", version.ref = "commonsIo" }
commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "commonsLang3" }
@@ -87,5 +88,5 @@ slf4j-handroid = { group = "com.gitlab.mvysny.slf4j", name = "slf4j-handroid", v
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "kspPlugin" }
dependencyLicenseReport = { id = "com.github.jk1.dependency-license-report", version.ref = "dependencyLicenseReport" }

View File

@@ -1,7 +1,7 @@
#Sat Sep 28 01:39:16 AM EDT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -0,0 +1,84 @@
# SPDX-FileCopyrightText: 2025 Zayed Al-Saidi <zayed.alsaidi@gmail.com>
#. extracted from ./metadata/android/en-US/full_description.txt
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-11-05 12:31+0000\n"
"PO-Revision-Date: 2025-02-09 17:40+0400\n"
"Last-Translator: Zayed Al-Saidi <zayed.alsaidi@gmail.com>\n"
"Language-Team: ar\n"
"Language: ar\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
"X-Generator: Lokalize 23.08.5\n"
msgid ""
"KDE Connect provides a set of features to integrate your workflow across "
"devices:\n"
"\n"
"- Transfer files between your devices.\n"
"- Access files on your phone from your computer, without wires.\n"
"- Shared clipboard: copy and paste between your devices.\n"
"- Get notifications for incoming calls and messages on your computer.\n"
"- Virtual touchpad: Use your phone screen as your computer's touchpad.\n"
"- Notifications sync: Access your phone notifications from your computer and "
"reply to messages.\n"
"- Multimedia remote control: Use your phone as a remote for Linux media "
"players.\n"
"- WiFi connection: no USB wire or bluetooth needed.\n"
"- End-to-end TLS encryption: your information is safe.\n"
"\n"
"Please note you will need to install KDE Connect on your computer for this "
"app to work, and keep the desktop version up-to-date with the Android "
"version for the latest features to work.\n"
"\n"
"Sensitive permissions information:\n"
"* Accessibility permission: Required to receive input from another device to "
"control your Android phone, if you use the Remote Input feature.\n"
"* Background location permission: Required to know to which WiFi network you "
"are connected to, if you use the Trusted Networks feature.\n"
"\n"
"KDE Connect never sends any information to KDE nor to any third party. KDE "
"Connect sends data from one device to the other directly using the local "
"network, never through the internet, and using end to end encryption.\n"
"\n"
"This app is part of an open source project and it exists thanks to all the "
"people who contributed to it. Visit the website to grab the source code.\n"
msgstr ""
"يوفر كِيدِي المتّصل مجموعة من الميزات لدمج سير عملك عبر الأجهزة:\n"
"\n"
"- نقل الملفات بين أجهزتك.\n"
"- الوصول إلى الملفات الموجودة على هاتفك من جهاز الكمبيوتر الخاص بك، دون "
"أسلاك.\n"
"- الحافظة المشتركة: النسخ واللصق بين أجهزتك.\n"
"- الحصول على إشعارات للمكالمات والرسائل الواردة على جهاز الكمبيوتر الخاص "
"بك.\n"
"- لوحة اللمس الافتراضية: استخدم شاشة هاتفك كلوحة لمس لجهاز الكمبيوتر الخاص "
"بك.\n"
"- مزامنة الإشعارات: الوصول إلى إشعارات هاتفك من جهاز الكمبيوتر الخاص بك "
"والرد على الرسائل.\n"
"- التحكم عن بعد في الوسائط المتعددة: استخدم هاتفك كجهاز تحكم عن بعد لمشغلات "
"الوسائط لينكس.\n"
"- اتصال WiFi: لا حاجة إلى سلك USB أو بلوتوث.\n"
"- تشفير TLS من البداية إلى النهاية: معلوماتك آمنة.\n"
"\n"
"يرجى ملاحظة أنك ستحتاج إلى تثبيت كِيدِي المتّصل على حاسوبك حتى يعمل هذا "
"التطبيق، والحفاظ على تحديث إصدار سطح المكتب بإصدار أندوريد حتى تعمل أحدث "
"الميزات.\n"
"\n"
"معلومات الأذونات الحساسة:\n"
"* إذن إمكانية الوصول: مطلوب لتلقي إدخال من جهاز آخر للتحكم في هاتف أندرويد "
"خاص بك، إذا كنت تستخدم ميزة الإدخال عن بُعد.\n"
"* إذن تحديد الموقع في الخلفية: مطلوب لمعرفة شبكة واي فاي التي تتصل بها، إذا "
"كنت تستخدم ميزة الشبكات الموثوقة.\n"
"\n"
"لا يرسل كِيدِي المتّصل أي معلومات إلى كيدي أو إلى أي طرف ثالث. يرسل كِيدِي المتّصل "
"البيانات من جهاز إلى آخر مباشرةً باستخدام الشبكة المحلية، وليس عبر الإنترنت، "
"وباستخدام التشفير من البداية إلى النهاية.\n"
"\n"
"هذا التطبيق جزء من مشروع مفتوح المصدر وهو موجود بفضل جميع الأشخاص الذين "
"ساهموا فيه. قم بزيارة الموقع الإلكتروني للحصول على الكود المصدر.\n"

View File

@@ -0,0 +1,20 @@
# SPDX-FileCopyrightText: 2025 Zayed Al-Saidi <zayed.alsaidi@gmail.com>
#. extracted from ./metadata/android/en-US/short_description.txt
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-11-05 12:31+0000\n"
"PO-Revision-Date: 2025-02-09 17:40+0400\n"
"Last-Translator: Zayed Al-Saidi <zayed.alsaidi@gmail.com>\n"
"Language-Team: ar\n"
"Language: ar\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
"X-Generator: Lokalize 23.08.5\n"
msgid "KDE Connect integrates your smartphone and computer"
msgstr "يقوم كِيدِي المتّصل بدمج هاتفك الذكي والحاسوب"

25
proguard-rules.pro vendored
View File

@@ -17,29 +17,10 @@
#}
-dontobfuscate
-dontwarn org.spongycastle.**
-dontwarn org.apache.sshd.**
-dontwarn org.apache.mina.**
-dontwarn org.slf4j.**
-dontwarn io.netty.**
-keepattributes SourceFile,LineNumberTable,Signature,*Annotation*
-keep class org.spongycastle.** {*;}
# SSHd requires mina, and mina uses reflection so some classes would get deleted
-keep class org.apache.mina.** {*;}
-keep class org.apache.sshd.** {*;}
-keep class org.kde.kdeconnect.** {*;}
-dontwarn org.mockito.**
-dontwarn sun.reflect.**
-dontwarn android.test.**
-dontwarn java.lang.management.**
-dontwarn javax.**
# SSHd requires mina, and mina uses reflection so some classes would get deleted
-keep class org.apache.sshd.** {*;}
-dontwarn org.apache.sshd.**
-dontwarn android.net.ConnectivityManager
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn android.net.LinkProperties

View File

@@ -21,6 +21,8 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:clipToPadding="false"
android:id="@+id/scroll_view"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout

View File

@@ -26,6 +26,7 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
tools:listitem="@layout/custom_device_item"/>
<TextView

View File

@@ -30,7 +30,8 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
android:id="@+id/device_view"
android:descendantFocusability="afterDescendants"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:clipToPadding="false">
<!-- Shown when the device is paired and reachable -->
<androidx.compose.ui.platform.ComposeView

View File

@@ -20,5 +20,6 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
android:id="@+id/licenses_text"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent"
android:clipToPadding="false"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -17,7 +17,8 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<!-- Keep in sync with toolbar.xml, copied here because it needs the nested TabLayout -->
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"

View File

@@ -57,6 +57,7 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
android:dividerHeight="0dp"
android:orientation="vertical"
android:paddingTop="8dp"
android:clipToPadding="false"
tools:context=".MainActivity" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -24,7 +24,7 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
app:drawableEndCompat="@drawable/ic_delete"
app:drawableStartCompat="@drawable/ic_delete" />
<FrameLayout
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/swipeableView"
android:layout_width="match_parent"
android:layout_height="match_parent"
@@ -32,17 +32,30 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<TextView
android:id="@+id/deviceNameOrIP"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="?android:selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
android:paddingEnd="?android:attr/listPreferredItemPaddingRight"
android:paddingStart="?android:attr/listPreferredItemPaddingLeft"
android:paddingEnd="?android:attr/listPreferredItemPaddingRight"
android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:visibility="visible"
tools:text="192.168.0.1"/>
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="192.168.0.1" />
</FrameLayout>
<TextView
android:id="@+id/connectionStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:paddingStart="?android:attr/listPreferredItemPaddingLeft"
android:paddingEnd="?android:attr/listPreferredItemPaddingRight"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View File

@@ -22,6 +22,7 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
android:descendantFocusability="afterDescendants"
android:dividerHeight="12dp"
android:orientation="vertical"
android:clipToPadding="false"
tools:listitem="@layout/list_card_entry"
tools:context=".MainActivity" />

View File

@@ -11,7 +11,9 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:fillViewport="true">
android:fillViewport="true"
android:clipToPadding="false"
android:id="@+id/scroll_view">
<LinearLayout
android:id="@+id/about_layout"

View File

@@ -17,6 +17,7 @@
<string name="pref_plugin_clipboard_sent">أرسل الحافظة</string>
<string name="pref_plugin_mousepad">الدّخل البعيد</string>
<string name="pref_plugin_mousepad_desc">استخدم الهاتف أو اللوحيّ كفأرة ولوحة مفاتيح</string>
<string name="pref_plugin_presenter">متحكم العرض التقديمي</string>
<string name="pref_plugin_mpris">تحكّمات الوسائط المتعدّدة</string>
<string name="pref_plugin_mpris_desc">توفّر تحكّمًا بعيدًا لمشغّل الوسائط</string>
<string name="pref_plugin_runcommand">شغّل أمرًا</string>
@@ -76,6 +77,7 @@
<string name="device_menu_plugins">إعدادات الملحقة</string>
<string name="device_menu_unpair">ألغِ الاقتران</string>
<string name="pair_new_device">اقرن جهازًا جديدًا</string>
<string name="cancel_pairing">ألغ الاقتران</string>
<string name="unknown_device">جهاز مجهول</string>
<string name="error_not_reachable">الجهاز غير قابل الوصول</string>
<string name="error_already_paired">الجهاز مقترن بالفعل</string>
@@ -83,8 +85,11 @@
<string name="error_canceled_by_user">ألغاه المستخدم</string>
<string name="error_canceled_by_other_peer">ألغاه ندّ آخر</string>
<string name="encryption_info_title">معلومات التّعمية</string>
<string name="my_device_fingerprint">بصمة SHA256 لشهادة جهازك هي:</string>
<string name="remote_device_fingerprint">بصمة SHA256 لشهادة الجهاز البعيد هي:</string>
<string name="pair_requested">طُلب الاقتران</string>
<string name="pair_succeeded">نجح الاقتران</string>
<string name="pairing_request_from">اطلب اقتران من \'%1s\'</string>
<string name="tap_to_open">اطرق لتفتح</string>
<string name="received_file_text">المس لفتح \'%1s\'</string>
<string name="tap_to_answer">المس للإجابة</string>
@@ -173,6 +178,7 @@
<string name="clipboard_toast">نُسخ إلى الحافظة</string>
<string name="runcommand_notreachable">الجهاز غير قابل الوصول</string>
<string name="runcommand_notpaired">الجهاز غير مقترن</string>
<string name="runcommand_category_device_controls_title">متحكمات الجهاز</string>
<string name="pref_plugin_findremotedevice">اعثر على جهاز بعيد</string>
<string name="pref_plugin_findremotedevice_desc">رنّ على الجهاز البعيد</string>
<string name="ring">رّن</string>
@@ -183,6 +189,8 @@
<string name="settings_rename">اسم الجهاز</string>
<string name="settings_dark_mode">سمة مظلمة</string>
<string name="settings_more_settings_title">المزيد من الإعدادات</string>
<string name="setting_persistent_notification_oreo">الإخطارات المستمرّة</string>
<string name="extra_options">الخيارات الإضافية</string>
<string name="privacy_options">خيارات الخصوصية</string>
<string name="set_privacy_options">حدد خيارات الخصوصية</string>
<string name="block_contents">امنح محتويات الإخطارات</string>

View File

@@ -197,6 +197,7 @@
<string name="invalid_device_name">Невалидно име на устройство</string>
<string name="shareplugin_text_saved">Получен текст, запазен в клипборда</string>
<string name="custom_devices_settings">Списък с потребителски устройства</string>
<string name="custom_devices_settings_summary">%d устройства, добавени ръчно</string>
<string name="custom_device_list">Добавяне на устройства по IP адрес</string>
<string name="custom_device_deleted">Изтрито потребителско устройство</string>
<string name="custom_device_list_help">Ако устройството ви не е открито автоматично, можете да добавите неговия IP адрес или име на хост, като щракнете върху плаващия бутон за действие</string>
@@ -420,4 +421,9 @@
<string name="mpris_keepwatching_settings_title">Продължаване на възпроизвеждането</string>
<string name="mpris_keepwatching_settings_summary">Показване на безшумно известие за продължаване на възпроизвеждането на това устройство след затваряне на медия</string>
<string name="notification_channel_keepwatching">Продължаване на възпроизвеждането</string>
<string name="ping_result">Извършен е пинг за %1$d милисекунди</string>
<string name="ping_failed">Устройството не можа да изпрати пинг</string>
<string name="ping_in_progress">Изпращане на пинг…</string>
<string name="device_host_invalid">Хостът е невалиден. Използвайте валидно име на хост, IPv4 или IPv6</string>
<string name="device_host_duplicate">Хостът вече е в списъка</string>
</resources>

View File

@@ -115,6 +115,7 @@
<string name="error_not_reachable">No es pot accedir al dispositiu</string>
<string name="error_already_paired">El dispositiu ja està aparellat</string>
<string name="error_timed_out">Ha excedit el temps</string>
<string name="error_clocks_not_match">Els rellotges dels dispositius no estan sincronitzats</string>
<string name="error_canceled_by_user">Cancel·lat per l\'usuari</string>
<string name="error_canceled_by_other_peer">Cancel·lat per l\'altre parell</string>
<string name="encryption_info_title">Informació de l\'encriptatge</string>
@@ -192,11 +193,13 @@
<string name="share_to">Comparteix amb…</string>
<string name="unreachable_device">%s (no s\'hi pot accedir)</string>
<string name="unreachable_device_url_share_text">Els URL compartits amb un dispositiu no accessible es lliuraran un cop s\'hi pugui accedir.\n\n</string>
<string name="protocol_version">Versió del protocol:</string>
<string name="protocol_version_newer">Aquest dispositiu usa una versió nova del protocol</string>
<string name="plugin_settings_with_name">Configuració del %s</string>
<string name="invalid_device_name">El nom del dispositiu no és vàlid</string>
<string name="shareplugin_text_saved">S\'ha rebut text i s\'ha desat al porta-retalls</string>
<string name="custom_devices_settings">Llista personalitzada de dispositius</string>
<string name="custom_devices_settings_summary">S\'han afegir %d dispositius manualment</string>
<string name="custom_device_list">Afegeix dispositius per la IP</string>
<string name="custom_device_deleted">S\'ha suprimit un dispositiu personalitzat</string>
<string name="custom_device_list_help">Si el dispositiu no es detecta automàticament, podeu afegir la seva adreça IP o el nom de la màquina fent clic al botó flotant d\'acció</string>
@@ -420,4 +423,9 @@
<string name="mpris_keepwatching_settings_title">Continua reproduint</string>
<string name="mpris_keepwatching_settings_summary">Mostra una notificació silenciosa per a continuar reproduint en aquest dispositiu després de tancar l\'element multimèdia</string>
<string name="notification_channel_keepwatching">Continua reproduint</string>
<string name="ping_result">S\'ha fet «ping» en %1$d mil·lisegons</string>
<string name="ping_failed">No s\'ha pogut fer «ping» al dispositiu</string>
<string name="ping_in_progress">S\'està fent «ping»…</string>
<string name="device_host_invalid">L\'ordinador no és vàlid. Useu un nom d\'ordinador, IPv4 o IPv6 vàlids</string>
<string name="device_host_duplicate">L\'ordinador ja existeix a la llista</string>
</resources>

View File

@@ -389,6 +389,8 @@
<string name="send_compose">Sendi</string>
<string name="compose_send_title">Verki sendon</string>
<string name="open_compose_send">Verki tekston</string>
<string name="double_tap_to_drag">Duoble frapetu por treni</string>
<string name="hold_to_drag">Teni por treni</string>
<string name="about_kde_about">&lt;h1&gt;Pri&lt;/h1&gt; &lt;p&gt;KDE estas tutmonda komunumo de softvar-inĝenieroj, artistoj, verkistoj, tradukistoj kaj kreintoj kiuj engaĝiĝas al &lt;a href=https://www.gnu.org/philosophy/free -sw.html&gt;Disvolvado de Libera Programaro&lt;/a&gt;. KDE produktas la Plasma labortablan medion, centojn da aplikaĵoj, kaj la multajn programarajn bibliotekojn kiuj subtenas ilin.&lt;/p&gt; &lt;/p&gt;KDE estas kunlabora entrepreno: neniu unuopa ento stiras ĝian direkton aŭ produktojn. Anstataŭe, ni kunlaboras por atingi la komunan celon konstrui la plej bonan Liberan Programaron de la mondo. Ĉiuj bonvenas &lt;a href=https://community.kde.org/Get_Involved&gt;aliiĝi kaj kontribui&lt;/a&gt; al KDE, inkluzive de vi.&lt;/p&gt; Vizitu &lt;a href=https://www.kde.org/&gt;https://www.kde.org/&lt;/a&gt; por pliaj informoj pri la KDE-komunumo kaj la programaro, kiun ni produktas.</string>
<string name="about_kde_report_bugs_or_wishes">&lt;h1&gt;Raporti Cimojn aŭ Dezirojn&lt;/h1&gt; &lt;p&gt;Softvaro ĉiam povas esti plibonigita kaj la KDE-teamo pretas fari tion. Tamen, vi - la uzanto - devas diri al ni se io ne funkcias kiel atendite aŭ povus esti farata pli bone.&lt;/p&gt; &lt;p&gt;KDE havas cimraportan sistemon. Vizitu &lt;a href=https://bugs.kde.org/&gt;https://bugs.kde.org/&lt;/a&gt; aŭ uzu la butonon \"Raporti Cimon\" el la Pri-ekrano por raporti cimojn.&lt;/p&gt; Se vi havas sugeston por plibonigo, vi estas bonvena uzi la cimtrakan sistemon pro registri vian deziron. Certigu, ke vi uzas la severecon nomita \"Wishlist\".</string>
<string name="about_kde_join_kde">&lt;h1&gt;Kuniĝu al KDE&lt;/h1&gt; &lt;p&gt;Vi ne devas esti programisto por esti membro de la teamo KDE. Vi povas aliĝi al la lingvoteamoj kiuj tradukas program-interfacojn. Vi povas provizi grafikaĵojn, etosojn, sonojn, kaj plibonigitan dokumentadon. Vi decidas!&lt;/p&gt; &lt;p&gt;Vizitu &lt;a href=https://community.kde.org/Get_Involved&gt;https://community.kde.org/Get_Involved&lt;/a&gt; por informo pri iuj projektoj en kiuj vi povas partopreni.&lt;/p&gt; Se vi bezonas plian informon aŭ dorkmentadon, vizito al &lt;a href=https://techbase.kde.org/&gt;https://techbase.kde.org/&lt;/a&gt; provizos al vi kion vi bezonas.</string>
@@ -403,6 +405,7 @@
<string name="maxim_leshchenko_task">UI-plibonigoj kaj ĉi tiu pri paĝo</string>
<string name="holger_kaelberer_task">Fora klavara kromaĵo kaj korektoj de cimoj</string>
<string name="saikrishna_arcot_task">Subteno por uzi la klavaron en la fora eniga kromaĵo, korektoj de cimoj kaj ĝeneralaj plibonigoj</string>
<string name="shellwen_chen_task">Plibonigi la sekurecon de SFTP, plibonigi la funkciteneblon de ĉi projekto, cimkorektoj kaj ĝeneralaj plibonigoj</string>
<string name="everyone_else">Ĉiuj aliaj, kiuj kontribuis al KDE Connect tra la jaroj</string>
<string name="send_clipboard">Sendi tondujon</string>
<string name="tap_to_execute">Frapi por plenumi</string>
@@ -415,4 +418,9 @@
<string name="mpris_keepwatching_settings_title">Daŭrigi ludadon</string>
<string name="mpris_keepwatching_settings_summary">Montri silentan sciigon por daŭrigi ludadon en ĉi tiu aparato post fermo de datumportilo</string>
<string name="notification_channel_keepwatching">Daŭrigi ludadon</string>
<string name="ping_result">Eĥosondis en %1$d milisekundoj</string>
<string name="ping_failed">Ne eblis eĥosondi aparaton</string>
<string name="ping_in_progress">Eĥosondante…</string>
<string name="device_host_invalid">Gastiganto estas nevalida. Uzu validan gastigantnomon, IPv4, aŭ IPv6</string>
<string name="device_host_duplicate">Gastiganto jam ekzistas en la listo</string>
</resources>

View File

@@ -115,6 +115,7 @@
<string name="error_not_reachable">No se encuentra el dispositivo</string>
<string name="error_already_paired">Dispositivo ya vinculado</string>
<string name="error_timed_out">Se ha agotado el tiempo</string>
<string name="error_clocks_not_match">Los relojes de los dispositivos no están sincronizados</string>
<string name="error_canceled_by_user">Cancelado por el usuario</string>
<string name="error_canceled_by_other_peer">Cancelado por la otra parte</string>
<string name="encryption_info_title">Información de cifrado</string>
@@ -192,11 +193,13 @@
<string name="share_to">Compartir con...</string>
<string name="unreachable_device">%s (no accesible)</string>
<string name="unreachable_device_url_share_text">Las URLs compartidas con dispositivos no accesibles se entregarán una vez que vuelvan a estar accesibles.\n\n</string>
<string name="protocol_version">Versión del protocolo:</string>
<string name="protocol_version_newer">Este dispositivo usa una versión más reciente del protocolo</string>
<string name="plugin_settings_with_name">Preferencias de %s</string>
<string name="invalid_device_name">Nombre de dispositivo no válido</string>
<string name="shareplugin_text_saved">Texto recibido y guardado en el portapapeles</string>
<string name="custom_devices_settings">Lista de dispositivos personalizada</string>
<string name="custom_devices_settings_summary">%d dispositivos añadidos manualmente</string>
<string name="custom_device_list">Añadir dispositivos por IP</string>
<string name="custom_device_deleted">Dispositivo personalizado borrado</string>
<string name="custom_device_list_help">Si su dispositivo no es detectado automáticamente puede añadir su dirección IP o nombre pulsando el botón de acción flotante</string>
@@ -420,4 +423,9 @@
<string name="mpris_keepwatching_settings_title">Continuar reproduciendo</string>
<string name="mpris_keepwatching_settings_summary">Mostrar una notificación silenciosa para continuar reproduciendo en este dispositivo tras cerrar el reproductor.</string>
<string name="notification_channel_keepwatching">Continuar reproduciendo</string>
<string name="ping_result">Conectado en %1$d mili-segundos</string>
<string name="ping_failed">No se pudo contactar con el dispositivo</string>
<string name="ping_in_progress">Conectando</string>
<string name="device_host_invalid">El servidor es inválido. Use un nombre válido de servidor, IPv4 o IPv6</string>
<string name="device_host_duplicate">El servidor ya existe en la lista</string>
</resources>

View File

@@ -197,6 +197,7 @@
<string name="invalid_device_name">Gailuaren izen baliogabea</string>
<string name="shareplugin_text_saved">Testua jaso da, arbelean kopiatu da</string>
<string name="custom_devices_settings">Gailuen zerrenda pertsonalizatua</string>
<string name="custom_devices_settings_summary">%d gailua eskuz gehitu dira</string>
<string name="custom_device_list">Gehitu gailuak IP bidez</string>
<string name="custom_device_deleted">Norberak finkatutako gailua ezabatu da</string>
<string name="custom_device_list_help">Zure gailua ez bada automatikoki detektatzen bere IP helbidea edo ostalari-izena gehitu dezakezu ekintza botoi mugikorrean klik eginez</string>
@@ -420,4 +421,9 @@
<string name="mpris_keepwatching_settings_title">Jarraitu jotzen</string>
<string name="mpris_keepwatching_settings_summary">Hedabidea itxi ondoren, gailu honetan jotzen jarraitzeko jakinarazpen ixil bat erakutsi.</string>
<string name="notification_channel_keepwatching">Jarraitu jotzen</string>
<string name="ping_result">«Ping» %1$d milisegundotan egin da</string>
<string name="ping_failed">Ezin izan dio gailuari «ping» egin</string>
<string name="ping_in_progress">«Ping» egiten...</string>
<string name="device_host_invalid">Ostalaria baliogabea da. Erabili balio duen ostalari-izen bat, IPv4, edo IPv6</string>
<string name="device_host_duplicate">Ostalaria jada zerrendan dago</string>
</resources>

View File

@@ -197,6 +197,7 @@
<string name="invalid_device_name">Virheellinen laitenimi</string>
<string name="shareplugin_text_saved">Vastaanotettiin tekstiä, tallennettiin leikepöydälle</string>
<string name="custom_devices_settings">Omien laitteiden luettelo</string>
<string name="custom_devices_settings_summary">Käyttäjä lisäsi %d laitetta</string>
<string name="custom_device_list">Lisää laitteita IP:llä</string>
<string name="custom_device_deleted">Poistettiin mukautettu laite</string>
<string name="custom_device_list_help">Ellei laitetta tunnisteta automaattisesti, sen IP-osoitteen tai konenimen voi lisätä napsauttamalla kelluvaa toimintopainiketta</string>
@@ -420,4 +421,9 @@
<string name="mpris_keepwatching_settings_title">Jatka toistoa</string>
<string name="mpris_keepwatching_settings_summary">Näytä hiljainen ilmoitus toiston jatkamisesta laitteella median sulkeuduttua</string>
<string name="notification_channel_keepwatching">Jatka toistoa</string>
<string name="ping_result">Pingattiin %1$d millisekunnissa</string>
<string name="ping_failed">Laitetta ei voitu pingata</string>
<string name="ping_in_progress">Pingataan…</string>
<string name="device_host_invalid">Konenimi on virheellinen. Käytä kelvollista konenimeä tai IPv4- tai IPv6-osoitetta.</string>
<string name="device_host_duplicate">Konenimi löytyy jo luettelosta</string>
</resources>

View File

@@ -115,6 +115,7 @@
<string name="error_not_reachable">Périphérique inaccessible</string>
<string name="error_already_paired">Périphérique déjà associé</string>
<string name="error_timed_out">Délai expiré</string>
<string name="error_clocks_not_match">Les horloges du périphérique ne sont pas synchronisées.</string>
<string name="error_canceled_by_user">Annulé par l\'utilisateur</string>
<string name="error_canceled_by_other_peer">Annulé par un autre homologue</string>
<string name="encryption_info_title">Informations de chiffrement</string>
@@ -192,11 +193,13 @@
<string name="share_to">Partager vers…</string>
<string name="unreachable_device">%s (Inaccessible)</string>
<string name="unreachable_device_url_share_text">Les URL partagées vers un appareil inaccessible lui seront transmises une fois qu\'il re-deviendra accessible.\n\n</string>
<string name="protocol_version">Version de protocole :</string>
<string name="protocol_version_newer">Le périphérique utilise une version plus récente du protocole</string>
<string name="plugin_settings_with_name">Configuration %s</string>
<string name="invalid_device_name">Nom de périphérique non valable</string>
<string name="shareplugin_text_saved">Texte reçu et enregistré dans le presse-papiers</string>
<string name="custom_devices_settings">Liste personnalisée de périphériques</string>
<string name="custom_devices_settings_summary">%d périphériques ajoutés de façon manuelle</string>
<string name="custom_device_list">Ajouter des périphériques par IP</string>
<string name="custom_device_deleted">Périphérique personnalisé supprimé</string>
<string name="custom_device_list_help">Si votre périphérique n\'est pas détecté automatiquement, vous pouvez ajouter son adresse IP ou son nom d\'hôte en cliquant sur le bouton d\'action flottant</string>
@@ -420,4 +423,9 @@
<string name="mpris_keepwatching_settings_title">Continuer la lecture</string>
<string name="mpris_keepwatching_settings_summary">Afficher une notification silencieuse pour continuer à jouer sur ce périphérique après la fermeture du média.</string>
<string name="notification_channel_keepwatching">Continuer la lecture</string>
<string name="ping_result">Interrogé en %1$d millisecondes</string>
<string name="ping_failed">Il est impossible d\'interroger un périphérique (Par ping)</string>
<string name="ping_in_progress">Interrogation par ping en cours...</string>
<string name="device_host_invalid">L\'hôte est non valable. Veuillez utiliser un nom d\'hôte valable, IPv4 ou IPv6</string>
<string name="device_host_duplicate">L\'hôte existe déjà dans la liste.</string>
</resources>

View File

@@ -115,6 +115,7 @@
<string name="error_not_reachable">O dispositivo está inaccesíbel.</string>
<string name="error_already_paired">O dispositivo xa está emparellado.</string>
<string name="error_timed_out">Esgotouse o tempo límite</string>
<string name="error_clocks_not_match">Os reloxos do dispositivos non están sincronizados.</string>
<string name="error_canceled_by_user">Cancelouno a persoa usuaria.</string>
<string name="error_canceled_by_other_peer">Cancelouse remotamente</string>
<string name="encryption_info_title">Información do cifrado</string>
@@ -192,11 +193,13 @@
<string name="share_to">Compartir con…</string>
<string name="unreachable_device">%s (inaccesíbel)</string>
<string name="unreachable_device_url_share_text">Os URL que se compartan cun dispositivo inaccesíbel entregaranse cando o dispositivo estea accesíbel.\n\n</string>
<string name="protocol_version">Versión do protocolo:</string>
<string name="protocol_version_newer">Este dispositivo usa unha versión máis nova do protocolo.</string>
<string name="plugin_settings_with_name">Configuración de %s</string>
<string name="invalid_device_name">Nome de dispositivo incorrecto</string>
<string name="shareplugin_text_saved">Recibiuse un texto e gardouse no portapapeis</string>
<string name="custom_devices_settings">Lista de dispositivos personalizada</string>
<string name="custom_devices_settings_summary">%d dispositivos engadidos manualmente</string>
<string name="custom_device_list">Engadir dispositivos por IP</string>
<string name="custom_device_deleted">Eliminouse o dispositivo personalizado</string>
<string name="custom_device_list_help">Se o seu dispositivo non se detecta automaticamente pode engadir o seu enderezo IP ou nome de máquina premendo o botón flotante de acción</string>
@@ -420,4 +423,9 @@
<string name="mpris_keepwatching_settings_title">Continuar reproducindo</string>
<string name="mpris_keepwatching_settings_summary">Amosar unha notificación silenciosa para continuar reproducindo neste dispositivo tras pechar o contido multimedia.</string>
<string name="notification_channel_keepwatching">Continuar reproducindo</string>
<string name="ping_result">Enviouse un ping en %1$d milisegundos</string>
<string name="ping_failed">Non foi posíbel enviar un ping ao dispositivo.</string>
<string name="ping_in_progress">Enviando un ping…</string>
<string name="device_host_invalid">O servidor non é válido. Use un nome de servidor, enderezo IPv4 ou enderezo IPv6 válido.</string>
<string name="device_host_duplicate">O servidor xa existe na lista.</string>
</resources>

View File

@@ -115,6 +115,7 @@
<string name="error_not_reachable">Dispositivo fuori portata</string>
<string name="error_already_paired">Dispositivo già associato</string>
<string name="error_timed_out">Richiesta scaduta</string>
<string name="error_clocks_not_match">Gli orologi dei dispositivi non sono sincronizzati</string>
<string name="error_canceled_by_user">Annullata dall\'utente</string>
<string name="error_canceled_by_other_peer">Annullata dal dispositivo remoto</string>
<string name="encryption_info_title">Informazioni di cifratura</string>
@@ -192,11 +193,13 @@
<string name="share_to">Condividi con…</string>
<string name="unreachable_device">%s (non raggiungibile)</string>
<string name="unreachable_device_url_share_text">Gli URL condivisi su un dispositivo irraggiungibile saranno recapitati una volta che sarà tornato raggiungibile.\n\n</string>
<string name="protocol_version">Versione del protocollo:</string>
<string name="protocol_version_newer">Questo dispositivo usa una nuova versione del protocollo di rete</string>
<string name="plugin_settings_with_name">Impostazioni di %s</string>
<string name="invalid_device_name">Nome dispositivo non valido</string>
<string name="shareplugin_text_saved">Testo ricevuto, salvato negli appunti</string>
<string name="custom_devices_settings">Elenco dispositivi personalizzati</string>
<string name="custom_devices_settings_summary">%d dispositivi aggiunti manualmente</string>
<string name="custom_device_list">Aggiungi dispositivi per IP</string>
<string name="custom_device_deleted">Dispositivo personalizzato eliminato</string>
<string name="custom_device_list_help">Se il tuo dispositivo non è rilevato automaticamente, puoi aggiungere il suo indirizzo IP o il nome host facendo clic sul pulsante Azione</string>
@@ -420,4 +423,9 @@
<string name="mpris_keepwatching_settings_title">Continua la riproduzione</string>
<string name="mpris_keepwatching_settings_summary">Mostra una notifica silenziosa per continuare a giocare su questo dispositivo dopo aver chiuso il supporto multimediale</string>
<string name="notification_channel_keepwatching">Continua la riproduzione</string>
<string name="ping_result">Ping effettuato in %1$d millisecondi</string>
<string name="ping_failed">Impossibile effettuare il ping del dispositivo</string>
<string name="ping_in_progress">Ping in corso…</string>
<string name="device_host_invalid">L\'host non è valido. Utilizza un nome host valido, IPv4 o IPv6</string>
<string name="device_host_duplicate">L\'host esiste già nell\'elenco</string>
</resources>

View File

@@ -115,6 +115,7 @@
<string name="error_not_reachable">המכשיר לא זמין</string>
<string name="error_already_paired">המכשיר כבר מצומד</string>
<string name="error_timed_out">נגמר הזמן</string>
<string name="error_clocks_not_match">שעוני המכשירים לא תואמים</string>
<string name="error_canceled_by_user">בוטל על ידי המשתמש</string>
<string name="error_canceled_by_other_peer">בוטל מהצד השני</string>
<string name="encryption_info_title">פרטי הצפנה</string>
@@ -208,11 +209,13 @@
<string name="share_to">שיתוף אל…</string>
<string name="unreachable_device">%s (לא נגיש)</string>
<string name="unreachable_device_url_share_text">כתובות שותפו להתקן בלתי נגיש והן תועברנה אליו ברגע שישוב להיות נגיש.\n\n</string>
<string name="protocol_version">גרסת פרוטוקול:</string>
<string name="protocol_version_newer">המכשיר משתמש בגרסה חדשה יותר</string>
<string name="plugin_settings_with_name">הגדרות %s</string>
<string name="invalid_device_name">שם המכשיר שגוי</string>
<string name="shareplugin_text_saved">התקבל טקסט, נשמר ללוח הגזירים</string>
<string name="custom_devices_settings">רשימת מכשירים מותאמת אישית</string>
<string name="custom_devices_settings_summary">%d התקנים נוספו ידנית</string>
<string name="custom_device_list">הוספת מכשירים לפי IP</string>
<string name="custom_device_deleted">מכשיר מותאם אישית נמחק</string>
<string name="custom_device_list_help">אם המכשיר שלך לא מזוהה אוטומטית אפשר להוסיף את כתובת ה־IP או את שם המארח שלו בלחיצה על כפתור הפעולה הצף</string>
@@ -252,7 +255,7 @@
<string name="pairing_description">מכשירים אחרים שמריצים KDE Connect ברשת הנוכחית צריכים להופיע פה.</string>
<string name="device_rename_title">שינוי שם מכשיר</string>
<string name="device_rename_confirm">שינוי שם</string>
<string name="refresh">רענון</string>
<string name="refresh">ריענון</string>
<string name="unreachable_description">ההתקן המצומד לא זמין, כדאי לוודא שהוא מחובר לאותה רשת כמוך.</string>
<string name="no_wifi">לא התחברת לאף רשת אלחוטית, לכן אין לך אפשרות לראות מכשירים כלשהם. לחיצה כאן תפעיל את הרשת האלחוטית.</string>
<string name="on_non_trusted_message">לא רשת מהימנה: גילוי אוטומטי מושבת.</string>
@@ -436,4 +439,9 @@
<string name="mpris_keepwatching_settings_title">להמשיך לנגן</string>
<string name="mpris_keepwatching_settings_summary">הצגת התראה שקטה כדי להמשיך לנגן בהתקן הזה לאחר סגירת המדיה</string>
<string name="notification_channel_keepwatching">להמשיך לנגן</string>
<string name="ping_result">הפינג ארך %1$d מילישניות</string>
<string name="ping_failed">לא ניתן לשלוח פינג להתקן</string>
<string name="ping_in_progress">נשלח פינג…</string>
<string name="device_host_invalid">המארח שגוי. נא להשתמש בשם מארח, IPv4 או IPv6 תקניים.</string>
<string name="device_host_duplicate">המארח כבר קיים ברשימה</string>
</resources>

View File

@@ -412,4 +412,9 @@
<string name="mpris_keepwatching_settings_title">계속 재생</string>
<string name="mpris_keepwatching_settings_summary">미디어를 닫은 후 이 장치에서 계속 재생할 수 있는 조용한 알림 표시</string>
<string name="notification_channel_keepwatching">계속 재생</string>
<string name="ping_result">핑 시간: %1$d 밀리초</string>
<string name="ping_failed">장치에 핑을 보낼 수 없음</string>
<string name="ping_in_progress">핑 진행 중…</string>
<string name="device_host_invalid">호스트가 잘못되었습니다. 올바른 호스트 이름, IPv4, IPv6 주소를 지정하십시오</string>
<string name="device_host_duplicate">호스트가 목록에 이미 있음</string>
</resources>

View File

@@ -115,6 +115,7 @@
<string name="error_not_reachable">Apparaat niet bereikbaar</string>
<string name="error_already_paired">Apparaat is al gepaard</string>
<string name="error_timed_out">Tijdslimiet overschreden</string>
<string name="error_clocks_not_match">Klokken op apparaten lopen niet gelijk</string>
<string name="error_canceled_by_user">Geannuleerd door gebruiker</string>
<string name="error_canceled_by_other_peer">Geannuleerd door andere kant</string>
<string name="encryption_info_title">Versleutelde informatie</string>
@@ -192,11 +193,13 @@
<string name="share_to">Delen met…</string>
<string name="unreachable_device">%s (niet bereikbaar)</string>
<string name="unreachable_device_url_share_text">URL\'s gedeeld met een niet bereikbaar apparaat zullen er afgeleverd worden wanneer deze bereikbaar wordt.\n\n</string>
<string name="protocol_version">Protocolversie:</string>
<string name="protocol_version_newer">Dit apparaat gebruikt een nieuwere protocolversie</string>
<string name="plugin_settings_with_name">Instellingen van %s</string>
<string name="invalid_device_name">Ongeldige apparaatnaam</string>
<string name="shareplugin_text_saved">Oontvangen tekst, opgeslagen op klembord</string>
<string name="custom_devices_settings">Aangepaste lijst apparaten</string>
<string name="custom_devices_settings_summary">%d apparaten handmatig toegevoegd</string>
<string name="custom_device_list">Voeg apparaten toe per IP-adres</string>
<string name="custom_device_deleted">Aangepaste apparaat verwijderd</string>
<string name="custom_device_list_help">Als uw apparaat niet automatisch wordt gedetecteerd kunt u zijn IP-adres of hostnaam toevoegen door te klikken op de knop Zwevende actie</string>
@@ -420,4 +423,9 @@
<string name="mpris_keepwatching_settings_title">Doorgaan met afspelen</string>
<string name="mpris_keepwatching_settings_summary">Een stille melding tonen om door te gaan met afspelen op dit apparaat na sluiten van het medium</string>
<string name="notification_channel_keepwatching">Doorgaan met afspelen</string>
<string name="ping_result">Ping verkregen in %1$d milliseconden</string>
<string name="ping_failed">Kreeg geen antwoord op ping van apparaat</string>
<string name="ping_in_progress">Ping wordt verstuurt</string>
<string name="device_host_invalid">Host is ongeldig. Gebruik een geldige hostnaam, IPv4 of IPv6</string>
<string name="device_host_duplicate">Host bestaat al in de lijst</string>
</resources>

View File

@@ -213,6 +213,7 @@
<string name="invalid_device_name">Nieprawidłowa nazwa urządzenia</string>
<string name="shareplugin_text_saved">Otrzymano tekst, zapisano do schowka</string>
<string name="custom_devices_settings">Lista własnych urządzeń</string>
<string name="custom_devices_settings_summary">%d urządzeń dodanych ręcznie</string>
<string name="custom_device_list">Dodaj urządzenie po adresie IP</string>
<string name="custom_device_deleted">Usunięto własne urządzenie</string>
<string name="custom_device_list_help">Jeśli twoje urządzenie nie zostało wykryte samoczynnie, to możesz dodać je ręcznie po wpisaniu jego adresu IP lub nazwy gospodarza. Aby to zrobić, naciśnij pomarańczowy przycisk u dołu ekranu.</string>
@@ -436,4 +437,9 @@
<string name="mpris_keepwatching_settings_title">Kontynuuj odtwarzanie</string>
<string name="mpris_keepwatching_settings_summary">Pokaż ciche powiadomienie, aby kontynuować odtwarzanie na tym urządzeniu po zamknięciu mediów</string>
<string name="notification_channel_keepwatching">Kontynuuj odtwarzanie</string>
<string name="ping_result">Odpowiedział w %1$d milisekund</string>
<string name="ping_failed">Nie można wysłać pingu do urządzenia</string>
<string name="ping_in_progress">Wysyłanie pingu…</string>
<string name="device_host_invalid">Nazwa gospodarza jest nieprawidłowa. Użyj prawidłowej nazwy, adresu IPv4 lub IPv6</string>
<string name="device_host_duplicate">Nazwa gospodarza już znajduje się na liście</string>
</resources>

View File

@@ -115,6 +115,7 @@
<string name="error_not_reachable">Naprava ni dosegljiva</string>
<string name="error_already_paired">Naprava je že uparjena</string>
<string name="error_timed_out">Čas je potekel</string>
<string name="error_clocks_not_match">Ure naprave niso sinhronizirane</string>
<string name="error_canceled_by_user">Preklical uporabnik</string>
<string name="error_canceled_by_other_peer">Preklican od drugega vrstnika</string>
<string name="encryption_info_title">Informacija o šifriranju</string>
@@ -130,10 +131,10 @@
<item quantity="other">Prejemanje %1$d datotek od %2$s</item>
</plurals>
<plurals name="incoming_files_text">
<item quantity="one">(Datotek %2$d od %3$d) : %1$s</item>
<item quantity="one">(Datoteka %2$d od %3$d) : %1$s</item>
<item quantity="two">(Datoteka %2$d od %3$d) : %1$s</item>
<item quantity="few">(Datoteki %2$d od %3$d) : %1$s</item>
<item quantity="other">(Datoteke %2$d od %3$d) : %1$s</item>
<item quantity="few">(Datoteka %2$d od %3$d) : %1$s</item>
<item quantity="other">(Datoteka %2$d od %3$d) : %1$s</item>
</plurals>
<plurals name="outgoing_file_title">
<item quantity="one">Pošiljanje %1$d datotek na %2$s</item>
@@ -208,11 +209,13 @@
<string name="share_to">Deli z…</string>
<string name="unreachable_device">%s (Nedosegljiva)</string>
<string name="unreachable_device_url_share_text">URL-ji, ki so v skupni rabi z nedosegljivo napravo, ji bodo dostavljeni, ko postane dosegljiva.\n\n</string>
<string name="protocol_version">Protokol različice:</string>
<string name="protocol_version_newer">Ta naprava uporablja novejšo različico protokola</string>
<string name="plugin_settings_with_name">Nastavitve %s</string>
<string name="invalid_device_name">Neveljavno ime naprave</string>
<string name="shareplugin_text_saved">Prejeto besedilo shranjeno na odložišče</string>
<string name="custom_devices_settings">Seznam naprav po meri</string>
<string name="custom_devices_settings_summary">%d naprav, dodanih ročno</string>
<string name="custom_device_list">Dodaj naprave po IP</string>
<string name="custom_device_deleted">Zbrisana naprava po meri</string>
<string name="custom_device_list_help">Če vaša naprava ni samodejno zaznana, lahko dodate njen IP naslov ali ime gostitelja s klikom na gumb Plavajoče dejanje</string>
@@ -436,4 +439,9 @@
<string name="mpris_keepwatching_settings_title">Nadaljuj s predvajanjem</string>
<string name="mpris_keepwatching_settings_summary">Pokaži tiho obvestilo za nadaljevanje predvajanja v tej napravi po zaprtju predstavnosti</string>
<string name="notification_channel_keepwatching">Nadaljuj s predvajanjem</string>
<string name="ping_result">Ping-an v %1$d milisekundah</string>
<string name="ping_failed">Ni bilo mogoče pingati naprave</string>
<string name="ping_in_progress">Pinganje…</string>
<string name="device_host_invalid">Gostitelj ni veljaven. Uporabite veljavno ime gostitelja, IPv4 ali IPv6</string>
<string name="device_host_duplicate">Gostitelj že obstaja na seznamu</string>
</resources>

View File

@@ -115,6 +115,7 @@
<string name="error_not_reachable">Apparaten kan inte nås</string>
<string name="error_already_paired">Apparat redan parkopplad</string>
<string name="error_timed_out">Tidsgräns överskriden</string>
<string name="error_clocks_not_match">Apparaternas klockor är inte synkroniserade</string>
<string name="error_canceled_by_user">Avbruten av användaren</string>
<string name="error_canceled_by_other_peer">Avbruten av motparten</string>
<string name="encryption_info_title">Krypteringsinformation</string>
@@ -192,11 +193,13 @@
<string name="share_to">Dela med…</string>
<string name="unreachable_device">%s (kan inte nås)</string>
<string name="unreachable_device_url_share_text">Webbadress som delas med en apparat som inte kan nås levereras till den när den väl blir möjlig att nå.\n\n</string>
<string name="protocol_version">Protokollversion:</string>
<string name="protocol_version_newer">Apparaten använder en nyare protokollversion</string>
<string name="plugin_settings_with_name">Inställningar av %s</string>
<string name="invalid_device_name">Ogiltigt apparatnamn</string>
<string name="shareplugin_text_saved">Tog emot text, spara på klippbordet</string>
<string name="custom_devices_settings">Egen apparatlista</string>
<string name="custom_devices_settings_summary">%d apparater manuellt tillagda</string>
<string name="custom_device_list">Lägg till apparater enligt IP-adress</string>
<string name="custom_device_deleted">Egen apparat borttagen</string>
<string name="custom_device_list_help">Om apparaten inte detekteras automatiskt kan dess IP-adress eller värddatornamn läggas till genom att klicka på den lösa åtgärdsknappen.</string>
@@ -420,4 +423,9 @@
<string name="mpris_keepwatching_settings_title">Fortsätt spela</string>
<string name="mpris_keepwatching_settings_summary">Visa en tyst underrättelse för att fortsätta spela på apparaten efter att media har stängts.</string>
<string name="notification_channel_keepwatching">Fortsätt spela</string>
<string name="ping_result">Skickade paket på %1$d millisekunder</string>
<string name="ping_failed">Kunde inte skicka paket till apparaten</string>
<string name="ping_in_progress">Skickar paket…</string>
<string name="device_host_invalid">Värddatorn är ogiltig. Använd ett giltigt värddatornamn, IPv4 eller IPv6</string>
<string name="device_host_duplicate">Värddatorn finns redan i listan</string>
</resources>

View File

@@ -115,6 +115,7 @@
<string name="error_not_reachable">Aygıt ulaşılabilir değil</string>
<string name="error_already_paired">Aygıt zaten eşleşmiş</string>
<string name="error_timed_out">Zaman aşımı</string>
<string name="error_clocks_not_match">Aygıt saatleri eşzamanlı değil</string>
<string name="error_canceled_by_user">Kullanıcı tarafından iptal edildi</string>
<string name="error_canceled_by_other_peer">Diğer eş tarafından iptal edildi</string>
<string name="encryption_info_title">Şifreleme Bilgisi</string>
@@ -192,11 +193,13 @@
<string name="share_to">Şuraya Paylaş…</string>
<string name="unreachable_device">%s (Erişilebilir değil)</string>
<string name="unreachable_device_url_share_text">Erişilemeyen bir aygıta gönderilen URLler, aygıt erişilebilir olduğunda teslim edilir.\n\n</string>
<string name="protocol_version">Protokol sürümü:</string>
<string name="protocol_version_newer">Bu aygıt, daha yeni bir protokol sürümü kullanıyor</string>
<string name="plugin_settings_with_name">%s ayarları</string>
<string name="invalid_device_name">Geçersiz aygıt adı</string>
<string name="shareplugin_text_saved">Gelen ileti, panoya kaydet</string>
<string name="custom_devices_settings">Özel aygıt listesi</string>
<string name="custom_devices_settings_summary">%d aygıt elle eklendi</string>
<string name="custom_device_list">IPye göre aygıtları ekle</string>
<string name="custom_device_deleted">Özel aygıt silindi</string>
<string name="custom_device_list_help">Aygıtınız kendiliğinden algılanmazsa İşlem Düğmesine tıklayarak IP adresini veya ana bilgisayar adını ekleyebilirsiniz</string>
@@ -396,7 +399,7 @@
<string name="about_kde_report_bugs_or_wishes">&lt;h1&gt;Hataları veya İsteklerinizi Bildirin&lt;/h1&gt; &lt;p&gt;Yazılımlar her zaman iyileştirilebilir ve KDE takımın bunu yapmaya hazırdır. Ancak siz de bir şey beklendiği gibi gitmezse veya hata verirse bize bildirin.&lt;/p&gt; &lt;p&gt;KDEnin bir hata takip sistemi vardır. &lt;a href=https://bugs.kde.org/&gt;https://bugs.kde.org/&lt;/a&gt; adresini ziyaret edin veya hakkında ekranının Hata Bildir düğmesini kullanarak hataları bildirin.&lt;/p&gt; Bir iyileştirme için öneriniz varsa bunu bildirmek için hata takip sistemini kullanabilirsiniz; yalnızca “Wishlist” önceliğini kullandığınızdan emin olun.</string>
<string name="about_kde_join_kde">"&lt;h1&gt;KDEye Katılın&lt;/h1&gt; &lt;p&gt;KDE takımının bir üyesi olmak için yazılım geliştirici olmanıza gerek yoktur. Program arayüzlerini çeviren dil takımlarına katılabilirsiniz. Grafikler, temalar, sesler ve iyileştirilmiş belgelendirme sağlayabilirsiniz. Karar sizin!&lt;/p&gt; &lt;p&gt;Katılabileceğiniz bazı projeler hakkında bilgi almak için &lt;a href=https://community.kde.org/Get_Involved&gt;https://community.kde.org/Get_Involved&lt;/a&gt; sayfasını ziyaret edin.&lt;/p&gt; Daha fazla bilgiye veya belgeye gereksiniminiz varsa &lt;a href=https://techbase.kde.org/&gt;https://techbase.kde.org/&lt;/a&gt; sayfasında aradığınızı bulabilirsiniz."</string>
<string name="about_kde_support_kde">"&lt;h1&gt;KDEyi Destekleyin&lt;/h1&gt; &lt;p&gt;KDE yazılımları her zaman ücretsiz kalmayı sürdürecektir; ancak bunu oluşturmak bedava değildir. &lt;/p&gt; &lt;p&gt;Geliştirmeyi desteklemek için KDE topluluğu, kar amacı gütmeyen bir kuruluş olan KDE e.V.yi kurmuştur, bu topluluk KDE topluğunu yasal ve finansal konularda temsil eder. KDE e.V. hakkında daha fazla bilgi için &lt;a href=https://ev.kde.org/&gt;https://ev.kde.org/&lt;/a&gt; adresini ziyaret edin.&lt;/p&gt; &lt;p&gt;KDE, finansal da dahil olmak üzere her türlü katkıdan yarar sağlar. Maddi kaynaklarımızla, geliştiricilerimizin ve diğerlerinin katkıda bulunurken oluşan masraflarını karşılıyoruz. Ayrıca yasal destek ve konferanslar ve toplantılar için de kullanılmaktadır.&lt;/p&gt; &lt;p&gt;Emeklerimizi, finansal destekle desteklemeniz için &lt;a href=https://www.kde.org/community/donations/&gt;https://www.kde.org/community/donations/&lt;/a&gt; adresinde bulunan yollardan birini kullanabilirsiniz.&lt;/p&gt; Desteğiniz için şimdiden teşekkürler."</string>
<string name="maintainer_and_developer">Projeyi sürdüren ve geliştirici</string>
<string name="maintainer_and_developer">Bakımcı ve geliştirici</string>
<string name="developer">Geliştirici</string>
<string name="apple_support">macOS ve iOS desteği üzerinde çalışılmaktadır.</string>
<string name="bug_fixes_and_general_improvements">Hata düzeltmeleri ve genel iyileştirmeler</string>
@@ -420,4 +423,9 @@
<string name="mpris_keepwatching_settings_title">Oynamayı Sürdür</string>
<string name="mpris_keepwatching_settings_summary">Ortamı kapattıktan sonra bu aygıtta oynatmayı sürdürmek için sessiz bir bildirim göster</string>
<string name="notification_channel_keepwatching">Oynamayı Sürdür</string>
<string name="ping_result">%1$d milisaniye içinde pinglendi</string>
<string name="ping_failed">Aygıt pinglenemedi</string>
<string name="ping_in_progress">Pingleniyor…</string>
<string name="device_host_invalid">Makine geçersiz. Geçerli bir makine adı kullanın; IPv4 veya IPv6 gibi</string>
<string name="device_host_duplicate">Listede makine halihazırda var</string>
</resources>

View File

@@ -115,6 +115,7 @@
<string name="error_not_reachable">Немає доступу до пристрою</string>
<string name="error_already_paired">Пристрій вже пов’язано</string>
<string name="error_timed_out">Час очікування вичерпано</string>
<string name="error_clocks_not_match">Годинник пристрою не синхронізовано</string>
<string name="error_canceled_by_user">Скасовано користувачем</string>
<string name="error_canceled_by_other_peer">Скасовано з іншого вузла пов’язування</string>
<string name="encryption_info_title">Дані щодо шифрування</string>
@@ -208,11 +209,13 @@
<string name="share_to">Оприлюднити на…</string>
<string name="unreachable_device">%s (недоступний)</string>
<string name="unreachable_device_url_share_text">Адреси, які оприлюднено на недоступному пристрої, буде надіслано, щойно пристрій стане доступним.\n\n</string>
<string name="protocol_version">Версія протоколу:</string>
<string name="protocol_version_newer">На цьому пристрої використовується новіша версія протоколу</string>
<string name="plugin_settings_with_name">Параметри %s</string>
<string name="invalid_device_name">Некоректна назва пристрою</string>
<string name="shareplugin_text_saved">Отримано текст, збережено до буфера обміну даними</string>
<string name="custom_devices_settings">Список нетипових пристроїв</string>
<string name="custom_devices_settings_summary">%d пристроїв додано вручну</string>
<string name="custom_device_list">Додати пристрої за IP</string>
<string name="custom_device_deleted">Нетиповий пристрій вилучено</string>
<string name="custom_device_list_help">Якщо ваш пристрій не було виявлено автоматично, ви можете додати його IP-адресу або назву вузла, натиснувши рухому кнопку дій.</string>
@@ -436,4 +439,9 @@
<string name="mpris_keepwatching_settings_title">Продовжити відтворення</string>
<string name="mpris_keepwatching_settings_summary">Показати беззвучне сповіщення для продовження відтворення на цьому пристрої після закриття носія</string>
<string name="notification_channel_keepwatching">Продовжити відтворення</string>
<string name="ping_result">Виконано зондування за %1$d мілісекунд</string>
<string name="ping_failed">Не вдалося зондувати пристрій</string>
<string name="ping_in_progress">Зондування…</string>
<string name="device_host_invalid">Вузол є некоректним. Скористайтеся коректною назвою вузла, IPv4 або IPv6</string>
<string name="device_host_duplicate">Запис вузла вже є у списку</string>
</resources>

View File

@@ -115,6 +115,7 @@
<string name="error_not_reachable">设备不可及</string>
<string name="error_already_paired">设备已配对</string>
<string name="error_timed_out">超时</string>
<string name="error_clocks_not_match">设备时钟不同步</string>
<string name="error_canceled_by_user">已被用户取消</string>
<string name="error_canceled_by_other_peer">已被其他对等点取消</string>
<string name="encryption_info_title">加密信息</string>
@@ -184,11 +185,13 @@
<string name="share_to">分享到…</string>
<string name="unreachable_device">%s (无法访问)</string>
<string name="unreachable_device_url_share_text">URL 被分享到了不可访问的设备。它将在设备能够访问时自动传输到该设备。\n</string>
<string name="protocol_version">协议版本:</string>
<string name="protocol_version_newer">此设备使用较新版本的协议</string>
<string name="plugin_settings_with_name">%s设置</string>
<string name="invalid_device_name">无效的设备名</string>
<string name="shareplugin_text_saved">已收到文本,存至剪贴板</string>
<string name="custom_devices_settings">自定义设备列表</string>
<string name="custom_devices_settings_summary">手动添加了 %d 个设备</string>
<string name="custom_device_list">通过 IP 添加设备</string>
<string name="custom_device_deleted">自定义设备已删除</string>
<string name="custom_device_list_help">如果您的设备未被自动检测到,您点击浮动操作按钮可以添加它的 IP 地址或主机名。</string>
@@ -412,4 +415,9 @@
<string name="mpris_keepwatching_settings_title">继续播放</string>
<string name="mpris_keepwatching_settings_summary">关闭媒体后显示一条用于继续在此设备上播放的静音通知</string>
<string name="notification_channel_keepwatching">继续播放</string>
<string name="ping_result">Ping 的响应时间是 %1$d 毫秒</string>
<string name="ping_failed">无法 ping 设备</string>
<string name="ping_in_progress">正在执行 ping 命令…</string>
<string name="device_host_invalid">主机无效。请使用有效的主机名、IPv4 或 IPv6 地址</string>
<string name="device_host_duplicate">主机已在列表中</string>
</resources>

View File

@@ -189,6 +189,7 @@
<string name="invalid_device_name">無效的裝置名稱</string>
<string name="shareplugin_text_saved">已接收文字,並且儲存到剪貼簿</string>
<string name="custom_devices_settings">自訂裝置列表</string>
<string name="custom_devices_settings_summary">已成功加入 %d 台裝置</string>
<string name="custom_device_list">透過 IP 新增裝置</string>
<string name="custom_device_deleted">自訂裝置已刪除</string>
<string name="custom_device_list_help">如果未自動偵測到您的裝置,您可以透過點擊「浮動操作按鈕」來新增其 IP 位址或主機名稱</string>
@@ -412,4 +413,9 @@
<string name="mpris_keepwatching_settings_title">繼續播放</string>
<string name="mpris_keepwatching_settings_summary">顯示無聲通知以在關閉媒體後繼續在此裝置上播放</string>
<string name="notification_channel_keepwatching">繼續播放</string>
<string name="ping_result">成功傳送測試封包,花費 %1$d 毫秒</string>
<string name="ping_failed">無法向裝置傳送測試封包</string>
<string name="ping_in_progress">正在傳送測試封包…</string>
<string name="device_host_invalid">主機是無效的。請使用有效的主機名稱、IPv4 位址或 IPv6 位址</string>
<string name="device_host_duplicate">主機已在清單中</string>
</resources>

View File

@@ -176,6 +176,7 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<string name="error_not_reachable">Device not reachable</string>
<string name="error_already_paired">Device already paired</string>
<string name="error_timed_out">Timed out</string>
<string name="error_clocks_not_match">Device clocks are out of sync</string>
<string name="error_canceled_by_user">Canceled by user</string>
<string name="error_canceled_by_other_peer">Canceled by other peer</string>
<string name="encryption_info_title">Encryption Info</string>
@@ -264,11 +265,13 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<string name="share_to">Share to…</string>
<string name="unreachable_device">%s (Unreachable)</string>
<string name="unreachable_device_url_share_text">URLs shared to an unreachable device will be delivered to it once it becomes reachable.\n\n</string>
<string name="protocol_version">Protocol version:</string>
<string name="protocol_version_newer">This device uses a newer protocol version</string>
<string name="plugin_settings_with_name">%s settings</string>
<string name="invalid_device_name">Invalid device name</string>
<string name="shareplugin_text_saved">Received text, saved to clipboard</string>
<string name="custom_devices_settings">Custom device list</string>
<string name="custom_devices_settings_summary">%d devices added manually</string>
<string name="custom_device_list">Add devices by IP</string>
<string name="custom_device_deleted">Custom device deleted</string>
<string name="custom_device_list_help">If your device is not automatically detected you can add its IP address or hostname by clicking on the Floating Action Button</string>
@@ -581,4 +584,11 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<string name="mpris_keepwatching_settings_summary">Show a silent notification to continue playing on this device after closing media</string>
<string name="notification_channel_keepwatching">Continue playing</string>
<string name="ping_result">Pinged in %1$d milliseconds</string>
<string name="ping_failed">Could not ping device</string>
<string name="ping_in_progress">Pinging…</string>
<string name="device_host_invalid">Host is invalid. Use a valid hostname, IPv4, or IPv6</string>
<string name="device_host_duplicate">Host already exists in the list</string>
</resources>

View File

@@ -3,11 +3,9 @@ pluginManagement {
gradlePluginPortal()
google()
mavenCentral()
/* Needed for org.apache.sshd debugging
maven {
url = uri("https://jitpack.io")
}
*/
}
}
dependencyResolutionManagement {

View File

@@ -19,20 +19,18 @@ import androidx.annotation.WorkerThread;
import org.json.JSONException;
import org.kde.kdeconnect.Backends.BaseLink;
import org.kde.kdeconnect.Backends.BaseLinkProvider;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.DeviceHost;
import org.kde.kdeconnect.DeviceInfo;
import org.kde.kdeconnect.Helpers.DeviceHelper;
import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper;
import org.kde.kdeconnect.Helpers.ThreadHelper;
import org.kde.kdeconnect.Helpers.TrustedNetworkHelper;
import org.kde.kdeconnect.KdeConnect;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect.UserInterface.CustomDevicesActivity;
import org.kde.kdeconnect.UserInterface.SettingsFragment;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
@@ -58,7 +56,7 @@ import kotlin.text.Charsets;
* WiFi network. The first packet sent over a socket must be an
* {@link DeviceInfo#toIdentityPacket()}.
*
* @see #identityPacketReceived(NetworkPacket, Socket, LanLink.ConnectionStarted)
* @see #identityPacketReceived(NetworkPacket, Socket, LanLink.ConnectionStarted, boolean)
*/
public class LanLinkProvider extends BaseLinkProvider {
@@ -67,6 +65,7 @@ public class LanLinkProvider extends BaseLinkProvider {
final static int MAX_PORT = 1764;
final static int PAYLOAD_TRANSFER_MIN_PORT = 1739;
final static int MAX_IDENTITY_PACKET_SIZE = 1024 * 512;
final static int MAX_UDP_PACKET_SIZE = 1024 * 512;
final static long MILLIS_DELAY_BETWEEN_CONNECTIONS_TO_SAME_DEVICE = 500L;
@@ -80,7 +79,7 @@ public class LanLinkProvider extends BaseLinkProvider {
private ServerSocket tcpServer;
private DatagramSocket udpServer;
private MdnsDiscovery mdnsDiscovery;
private final MdnsDiscovery mdnsDiscovery;
private long lastBroadcast = 0;
private final static long delayBetweenBroadcasts = 200;
@@ -99,8 +98,7 @@ public class LanLinkProvider extends BaseLinkProvider {
NetworkPacket networkPacket;
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String message = reader.readLine();
String message = readSingleLine(socket);
networkPacket = NetworkPacket.unserialize(message);
//Log.e("TcpListener", "Received TCP packet: " + networkPacket.serialize());
} catch (Exception e) {
@@ -119,6 +117,25 @@ public class LanLinkProvider extends BaseLinkProvider {
identityPacketReceived(networkPacket, socket, LanLink.ConnectionStarted.Locally, deviceTrusted);
}
/**
* Read a single line from a socket without consuming anything else from the input.
*/
private String readSingleLine(Socket socket) throws IOException {
InputStream stream = socket.getInputStream();
StringBuilder line = new StringBuilder(MAX_IDENTITY_PACKET_SIZE);
int ch;
while ((ch = stream.read()) != -1) {
line.append((char) ch);
if (ch == '\n') {
return line.toString();
}
if (line.length() >= MAX_IDENTITY_PACKET_SIZE) {
break;
}
}
throw new IOException("Couldn't read a line from the socket");
}
//I've received their broadcast and should connect to their TCP socket and send my identity.
@WorkerThread
private void udpPacketReceived(DatagramPacket packet) throws JSONException, IOException {
@@ -126,7 +143,13 @@ public class LanLinkProvider extends BaseLinkProvider {
final InetAddress address = packet.getAddress();
String message = new String(packet.getData(), Charsets.UTF_8);
final NetworkPacket identityPacket = NetworkPacket.unserialize(message);
final NetworkPacket identityPacket;
try {
identityPacket = NetworkPacket.unserialize(message);
} catch (JSONException e) {
Log.w("KDE/LanLinkProvider", "Invalid identity packet received: " + e.getMessage());
return;
}
if (!DeviceInfo.isValidIdentityPacket(identityPacket)) {
Log.w("KDE/LanLinkProvider", "Invalid identity packet received.");
@@ -192,12 +215,7 @@ public class LanLinkProvider extends BaseLinkProvider {
/**
* Called when a new 'identity' packet is received. Those are passed here by
* {@link #tcpPacketReceived(Socket)} and {@link #udpPacketReceived(DatagramPacket)}.
* <p>
* Should be called on a new thread since it blocks until the handshake is completed.
* </p><p>
* If the remote device should be connected, this calls {@link #addLink}.
* Otherwise, if there was an Exception, we unpair from that device.
* </p>
*
* @param identityPacket identity of a remote device
* @param socket a new Socket, which should be used to receive packets from the remote device
@@ -219,40 +237,59 @@ public class LanLinkProvider extends BaseLinkProvider {
return;
}
// If I'm the TCP server I will be the SSL client and viceversa.
final boolean clientMode = (connectionStarted == LanLink.ConnectionStarted.Locally);
if (deviceTrusted && !SslHelper.isCertificateStored(context, deviceId)) {
//Device paired with and old version, we can't use it as we lack the certificate
Device device = KdeConnect.getInstance().getDevice(deviceId);
if (device == null) {
int protocolVersion = identityPacket.getInt("protocolVersion");
if (deviceTrusted && isProtocolDowngrade(deviceId, protocolVersion)) {
Log.w("KDE/LanLinkProvider", "Refusing to connect to a device using an older protocol version:" + protocolVersion);
return;
}
device.unpair();
//Retry as unpaired
identityPacketReceived(identityPacket, socket, connectionStarted, deviceTrusted);
if (deviceTrusted && !SslHelper.isCertificateStored(context, deviceId)) {
Log.e("KDE/LanLinkProvider", "Device trusted but no cert stored. This should not happen.");
return;
}
String deviceName = identityPacket.getString("deviceName", "unknown");
Log.i("KDE/LanLinkProvider", "Starting SSL handshake with " + deviceName + " trusted:" + deviceTrusted);
// If I'm the TCP server I will be the SSL client and viceversa.
final boolean clientMode = (connectionStarted == LanLink.ConnectionStarted.Locally);
final SSLSocket sslSocket = SslHelper.convertToSslSocket(context, socket, deviceId, deviceTrusted, clientMode);
sslSocket.addHandshakeCompletedListener(event -> {
// Start a new thread because some Android versions don't allow calling sslSocket.getOutputStream() from the callback
ThreadHelper.execute(() -> {
String mode = clientMode ? "client" : "server";
try {
NetworkPacket secureIdentityPacket;
if (protocolVersion >= 8) {
DeviceInfo myDeviceInfo = DeviceHelper.getDeviceInfo(context);
NetworkPacket myIdentity = myDeviceInfo.toIdentityPacket();
OutputStream writer = sslSocket.getOutputStream();
writer.write(myIdentity.serialize().getBytes(Charsets.UTF_8));
writer.flush();
String line = readSingleLine(sslSocket);
// Do not trust the identity packet we received unencrypted
secureIdentityPacket = NetworkPacket.unserialize(line);
if (!DeviceInfo.isValidIdentityPacket(secureIdentityPacket)) {
throw new JSONException("Invalid identity packet");
}
int newProtocolVersion = secureIdentityPacket.getInt("protocolVersion");
if (newProtocolVersion != protocolVersion) {
Log.w("KDE/LanLinkProvider", "Protocol version changed half-way through the handshake: " + protocolVersion + " ->" + newProtocolVersion);
}
} else {
secureIdentityPacket = identityPacket;
}
Certificate certificate = event.getPeerCertificates()[0];
DeviceInfo deviceInfo = DeviceInfo.fromIdentityPacketAndCert(identityPacket, certificate);
DeviceInfo deviceInfo = DeviceInfo.fromIdentityPacketAndCert(secureIdentityPacket, certificate);
Log.i("KDE/LanLinkProvider", "Handshake as " + mode + " successful with " + deviceName + " secured with " + event.getCipherSuite());
addOrUpdateLink(sslSocket, deviceInfo);
} catch (JSONException e) {
Log.e("KDE/LanLinkProvider", "Remote device doesn't correctly implement protocol version 8", e);
} catch (IOException e) {
Log.e("KDE/LanLinkProvider", "Handshake as " + mode + " failed with " + deviceName, e);
Device device = KdeConnect.getInstance().getDevice(deviceId);
if (device == null) {
return;
}
device.unpair();
}
});
});
//Handshake is blocking, so do it on another thread and free this thread to keep receiving new connection
Log.d("LanLinkProvider", "Starting handshake");
@@ -260,6 +297,12 @@ public class LanLinkProvider extends BaseLinkProvider {
Log.d("LanLinkProvider", "Handshake done");
}
private boolean isProtocolDowngrade(String deviceId, int protocolVersion) {
SharedPreferences devicePrefs = context.getSharedPreferences(deviceId, Context.MODE_PRIVATE);
int lastKnownProtocolVersion = devicePrefs.getInt("protocolVersion", 0);
return lastKnownProtocolVersion > protocolVersion;
}
/**
* Add or update a link in the {@link #visibleDevices} map.
*
@@ -384,19 +427,19 @@ public class LanLinkProvider extends BaseLinkProvider {
}
ThreadHelper.execute(() -> {
List<String> ipStringList = CustomDevicesActivity
.getCustomDeviceList(PreferenceManager.getDefaultSharedPreferences(context));
List<DeviceHost> hostList = CustomDevicesActivity
.getCustomDeviceList(context);
if (TrustedNetworkHelper.isTrustedNetwork(context)) {
ipStringList.add("255.255.255.255"); //Default: broadcast.
hostList.add(DeviceHost.BROADCAST); //Default: broadcast.
} else {
Log.i("LanLinkProvider", "Current network isn't trusted, not broadcasting");
}
ArrayList<InetAddress> ipList = new ArrayList<>();
for (String ip : ipStringList) {
for (DeviceHost host : hostList) {
try {
ipList.add(InetAddress.getByName(ip));
ipList.add(InetAddress.getByName(host.toString()));
} catch (UnknownHostException e) {
e.printStackTrace();
}
@@ -417,6 +460,8 @@ public class LanLinkProvider extends BaseLinkProvider {
return;
}
// TODO: In protocol version 8 this packet doesn't need to contain identity info
// since it will be exchanged after the socket is encrypted.
DeviceInfo myDeviceInfo = DeviceHelper.getDeviceInfo(context);
NetworkPacket identity = myDeviceInfo.toIdentityPacket();
identity.set("tcpPort", tcpServer.getLocalPort());
@@ -470,7 +515,9 @@ public class LanLinkProvider extends BaseLinkProvider {
setupTcpListener();
mdnsDiscovery.startDiscovering();
if (TrustedNetworkHelper.isTrustedNetwork(context)) {
mdnsDiscovery.startAnnouncing();
}
broadcastUdpIdentityPacket(null);
}

View File

@@ -216,6 +216,9 @@ public class MdnsDiscovery {
// Let the LanLinkProvider handle the connection
InetAddress remoteAddress = serviceInfo.getHost();
// TODO: In protocol version 8 we should be able to call "identityPacketReceived"
// here, since we already have all the info we need to start a connection
// and the remaining identity info will be exchanged later.
lanLinkProvider.sendUdpIdentityPacket(Collections.singletonList(remoteAddress), null);
}
};

View File

@@ -32,6 +32,7 @@ import org.kde.kdeconnect.Backends.BaseLinkProvider
import org.kde.kdeconnect.Backends.BaseLinkProvider.ConnectionReceiver
import org.kde.kdeconnect.Backends.BluetoothBackend.BluetoothLinkProvider
import org.kde.kdeconnect.Backends.LanBackend.LanLinkProvider
import org.kde.kdeconnect.Backends.LoopbackBackend.LoopbackLinkProvider
import org.kde.kdeconnect.Helpers.NotificationHelper
import org.kde.kdeconnect.Plugins.ClibpoardPlugin.ClipboardFloatingActivity
import org.kde.kdeconnect.Plugins.RunCommandPlugin.RunCommandActivity
@@ -50,7 +51,7 @@ import org.kde.kdeconnect_tp.R
class BackgroundService : Service() {
private lateinit var applicationInstance: KdeConnect
private val linkProviders = ArrayList<BaseLinkProvider>()
private val linkProviders = mutableListOf<BaseLinkProvider>()
private val connectedToNonCellularNetwork = MutableLiveData<Boolean>()
/** Indicates whether device is connected over wifi / usb / bluetooth / (anything other than cellular) */
@@ -148,8 +149,8 @@ class BackgroundService : Service() {
private fun createForegroundNotification(): Notification {
// Why is this needed: https://developer.android.com/guide/components/services#Foreground
val connectedDevices = ArrayList<String>()
val connectedDeviceIds = ArrayList<String>()
val connectedDevices = mutableListOf<String>()
val connectedDeviceIds = mutableListOf<String>()
for (device in applicationInstance.devices.values) {
if (device.isReachable && device.isPaired) {
connectedDeviceIds.add(device.deviceId)

View File

@@ -150,25 +150,27 @@ class Device : PacketReceiver {
val deviceType: DeviceType
get() = deviceInfo.type
val protocolVersion: Int
get() = deviceInfo.protocolVersion
val deviceId: String
get() = deviceInfo.id
val certificate: Certificate
get() = deviceInfo.certificate
val verificationKey: String?
get() = pairingHandler.verificationKey()
// Returns 0 if the version matches, < 0 if it is older or > 0 if it is newer
fun compareProtocolVersion(): Int =
deviceInfo.protocolVersion - DeviceHelper.ProtocolVersion
val isPaired: Boolean
get() = pairingHandler.state == PairingHandler.PairState.Paired
val isPairRequested: Boolean
get() = pairingHandler.state == PairingHandler.PairState.Requested
val isPairRequestedByPeer: Boolean
get() = pairingHandler.state == PairingHandler.PairState.RequestedByPeer
val pairStatus : PairingHandler.PairState
get() = pairingHandler.state
fun addPairingCallback(callback: PairingCallback) = pairingCallbacks.add(callback)
@@ -286,8 +288,6 @@ class Device : PacketReceiver {
val notificationManager = ContextCompat.getSystemService(context, NotificationManager::class.java)!!
val verificationKey = SslHelper.getVerificationKey(SslHelper.certificate, deviceInfo.certificate)
val noti = NotificationCompat.Builder(context, NotificationHelper.Channels.DEFAULT)
.setContentTitle(res.getString(R.string.pairing_request_from, name))
.setContentText(res.getString(R.string.pairing_verification_code, verificationKey))
@@ -358,10 +358,11 @@ class Device : PacketReceiver {
fun updateDeviceInfo(newDeviceInfo: DeviceInfo): Boolean {
var hasChanges = false
if (deviceInfo.name != newDeviceInfo.name || deviceInfo.type != newDeviceInfo.type) {
if (deviceInfo.name != newDeviceInfo.name || deviceInfo.type != newDeviceInfo.type || deviceInfo.protocolVersion != newDeviceInfo.protocolVersion) {
hasChanges = true
deviceInfo.name = newDeviceInfo.name
deviceInfo.type = newDeviceInfo.type
deviceInfo.protocolVersion = newDeviceInfo.protocolVersion
if (isPaired) {
deviceInfo.saveInSettings(settings)
}

View File

@@ -0,0 +1,65 @@
package org.kde.kdeconnect
import org.kde.kdeconnect.Helpers.ThreadHelper
import java.net.InetAddress
class DeviceHost private constructor(private val host: String) {
// Wrapper because Kotlin doesn't allow nested nullability
data class PingResult(val latency: Long?)
/** The amount of milliseconds the ping request took or null it's in progress */
var ping: PingResult? = null
private set
/**
* Checks if the host can be reached over the network.
* @param callback Callback for updating UI elements
*/
fun checkReachable(callback: () -> Unit) {
ThreadHelper.execute {
try {
val address = InetAddress.getByName(this.host)
val startTime = System.currentTimeMillis()
val pingable = address.isReachable(PING_TIMEOUT)
val delayMillis = System.currentTimeMillis() - startTime
val pingResult = PingResult(if (pingable) delayMillis else null)
ping = pingResult
}
catch (_: Exception) {
ping = PingResult(null)
}
callback()
}
}
init {
require(isValidDeviceHost(host)) { "Invalid host" }
}
override fun toString(): String {
return this.host
}
companion object {
/** Ping timeout */
private const val PING_TIMEOUT = 3_000
private val hostnameValidityPattern = Regex("^[0-9A-Za-z._-]+$")
@JvmStatic
fun isValidDeviceHost(host: String): Boolean {
return hostnameValidityPattern.matches(host)
}
@JvmStatic
fun toDeviceHostOrNull(host: String): DeviceHost? {
return if (isValidDeviceHost(host)) {
DeviceHost(host)
} else {
null
}
}
@JvmField
val BROADCAST: DeviceHost = DeviceHost("255.255.255.255")
}
}

View File

@@ -44,6 +44,7 @@ class DeviceInfo(
putString("certificate", encodedCertificate)
putString("deviceName", name)
putString("deviceType", type.toString())
putInt("protocolVersion", protocolVersion)
apply()
}
} catch (e: CertificateEncodingException) {
@@ -79,7 +80,8 @@ class DeviceInfo(
id = deviceId,
name = getString("deviceName", "unknown")!!,
type = DeviceType.fromString(getString("deviceType", "desktop")!!),
certificate = SslHelper.getDeviceCertificate(context, deviceId)
certificate = SslHelper.getDeviceCertificate(context, deviceId),
protocolVersion = getInt("protocolVersion", 0),
)
}
@@ -105,8 +107,13 @@ class DeviceInfo(
fun isValidIdentityPacket(identityPacket: NetworkPacket): Boolean = with(identityPacket) {
type == NetworkPacket.PACKET_TYPE_IDENTITY &&
DeviceHelper.filterName(getString("deviceName", "")).isNotBlank() &&
getString("deviceId", "").isNotBlank()
isValidDeviceId(getString("deviceId", ""));
}
private val DEVICE_ID_REGEX = "^[a-zA-Z0-9_-]{32,38}\$".toRegex()
@JvmStatic
fun isValidDeviceId(deviceId: String): Boolean = deviceId.matches(DEVICE_ID_REGEX)
}
}

View File

@@ -28,7 +28,7 @@ import java.nio.charset.StandardCharsets
import java.util.UUID
object DeviceHelper {
const val ProtocolVersion = 7
const val ProtocolVersion = 8
const val KEY_DEVICE_NAME_PREFERENCE = "device_name_preference"
private const val KEY_DEVICE_NAME_FETCHED_FROM_THE_INTERNET = "device_name_downloaded_preference"

View File

@@ -52,7 +52,6 @@ object SMSHelper {
private const val THUMBNAIL_WIDTH = 100
// The constant Telephony.Mms.Part.CONTENT_URI was added in API 29
@JvmField
val mMSPartUri : Uri = Uri.parse("content://mms/part/")
/**
@@ -94,10 +93,7 @@ object SMSHelper {
* @param context android.content.Context running the request
* @return Timestamp of the oldest known message.
*/
@JvmStatic
fun getNewestMessageTimestamp(
context: Context
): Long {
fun getNewestMessageTimestamp(context: Context): Long {
var oldestMessageTimestamp = Long.MIN_VALUE
val newestMessage = getMessagesInRange(context, null, Long.MAX_VALUE, 1L, true)
// There should only be one, but in case for some reason there are more, take the latest
@@ -117,12 +113,7 @@ object SMSHelper {
* @param numberToGet Number of messages to return. Pass null for "all"
* @return List of all messages in the thread
*/
@JvmStatic
fun getMessagesInThread(
context: Context,
threadID: ThreadID,
numberToGet: Long?
): List<Message> {
fun getMessagesInThread(context: Context, threadID: ThreadID, numberToGet: Long?): List<Message> {
return getMessagesInRange(context, threadID, Long.MAX_VALUE, numberToGet, true)
}
@@ -136,7 +127,6 @@ object SMSHelper {
* @param getMessagesOlderStartTime If true, get messages with timestamps before the startTimestamp. If false, get newer messages
* @return Some messages in the requested conversation
*/
@JvmStatic
@SuppressLint("NewApi")
fun getMessagesInRange(
context: Context,
@@ -441,10 +431,7 @@ object SMSHelper {
* @param context android.content.Context running the request
* @return Non-blocking iterable of the first message in each conversation
*/
@JvmStatic
fun getConversations(
context: Context
): Sequence<Message> {
fun getConversations(context: Context): Sequence<Message> {
val uri = getConversationUri()
// Used to avoid spewing logs in case there is an overall problem with fetching thread IDs
@@ -507,14 +494,10 @@ object SMSHelper {
}
threadTimestampPair.add(Pair(threadID, messageDate))
}
threadIds = threadTimestampPair.stream()
.sorted { left: Pair<ThreadID, Long>, right: Pair<ThreadID, Long> ->
right.second.compareTo(
left.second
)
} // Sort most-recent to least-recent (largest to smallest)
threadIds = threadTimestampPair
// Sort most-recent to least-recent (largest to smallest)
.sortedWith { left: Pair<ThreadID, Long>, right: Pair<ThreadID, Long> -> right.second.compareTo(left.second) }
.map { threadTimestampPairElement: Pair<ThreadID, Long> -> threadTimestampPairElement.first }
.collect(Collectors.toList())
}
// Step 2: Get the actual message object from each thread ID
@@ -538,25 +521,16 @@ object SMSHelper {
}
}
private fun addEventFlag(
oldEvent: Int,
eventFlag: Int
): Int {
private fun addEventFlag(oldEvent: Int, eventFlag: Int): Int {
return oldEvent or eventFlag
}
/**
* Parse all parts of an SMS into a Message
*/
private fun parseSMS(
context: Context,
messageInfo: Map<String, String?>
): Message {
var event = Message.EVENT_UNKNOWN
event = addEventFlag(event, Message.EVENT_TEXT_MESSAGE)
val address = listOf(
Address(context, messageInfo[Telephony.Sms.ADDRESS]!!)
)
private fun parseSMS(context: Context, messageInfo: Map<String, String?>): Message {
val event = addEventFlag(Message.EVENT_UNKNOWN, Message.EVENT_TEXT_MESSAGE)
val address = listOf(Address(context, messageInfo[Telephony.Sms.ADDRESS]!!))
val maybeBody = messageInfo.getOrDefault(Message.BODY, "")
val body = maybeBody ?: ""
val date = NumberUtils.toLong(messageInfo.getOrDefault(Message.DATE, null))
@@ -569,28 +543,14 @@ object SMSHelper {
)
)
val uID = NumberUtils.toLong(messageInfo.getOrDefault(Message.U_ID, null))
val subscriptionID =
NumberUtils.toInt(messageInfo.getOrDefault(Message.SUBSCRIPTION_ID, null))
val subscriptionID = NumberUtils.toInt(messageInfo.getOrDefault(Message.SUBSCRIPTION_ID, null))
// Examine all the required SMS columns and emit a log if something seems amiss
val anyNulls = Arrays.stream(
arrayOf(
Telephony.Sms.ADDRESS,
Message.BODY,
Message.DATE,
Message.TYPE,
Message.READ,
Message.THREAD_ID,
Message.U_ID
)
)
val anyNulls = arrayOf(Telephony.Sms.ADDRESS, Message.BODY, Message.DATE, Message.TYPE, Message.READ, Message.THREAD_ID, Message.U_ID)
.map { key: String -> messageInfo.getOrDefault(key, null) }
.anyMatch { obj: String? -> Objects.isNull(obj) }
.any { obj: String? -> Objects.isNull(obj) }
if (anyNulls) {
Log.e(
"parseSMS",
"Some fields were invalid. This indicates either a corrupted SMS database or an unsupported device."
)
Log.e("parseSMS", "Some fields were invalid. This indicates either a corrupted SMS database or an unsupported device.")
}
return Message(
address,
@@ -610,11 +570,7 @@ object SMSHelper {
* Parse all parts of the MMS message into a message
* Original implementation from https://stackoverflow.com/a/6446831/3723163
*/
private fun parseMMS(
context: Context,
messageInfo: Map<String, String?>,
userPhoneNumbers: List<LocalPhoneNumber>
): Message {
private fun parseMMS(context: Context, messageInfo: Map<String, String?>, userPhoneNumbers: List<LocalPhoneNumber>): Message {
var event = Message.EVENT_UNKNOWN
var body = ""
val read = NumberUtils.toInt(messageInfo[Message.READ])
@@ -747,8 +703,8 @@ object SMSHelper {
val to = SmsMmsUtils.getMmsTo(context, msg)
val addresses: MutableList<Address> = ArrayList()
if (from != null) {
val isLocalPhoneNumber = userPhoneNumbers.stream()
.anyMatch { localPhoneNumber: LocalPhoneNumber ->
val isLocalPhoneNumber = userPhoneNumbers
.any { localPhoneNumber: LocalPhoneNumber ->
localPhoneNumber.isMatchingPhoneNumber(from.getAddress())
}
if (!isLocalPhoneNumber && from.toString() != "insert-address-token") {
@@ -757,8 +713,8 @@ object SMSHelper {
}
if (to != null) {
for (toAddress in to) {
val isLocalPhoneNumber = userPhoneNumbers.stream()
.anyMatch { localPhoneNumber: LocalPhoneNumber ->
val isLocalPhoneNumber = userPhoneNumbers
.any { localPhoneNumber: LocalPhoneNumber ->
localPhoneNumber.isMatchingPhoneNumber(toAddress.getAddress())
}
if (!isLocalPhoneNumber && toAddress.toString() != "insert-address-token") {
@@ -837,16 +793,8 @@ object SMSHelper {
*
* @param observer ContentObserver to alert on Message changes
*/
@JvmStatic
fun registerObserver(
observer: ContentObserver,
context: Context
) {
context.contentResolver.registerContentObserver(
getConversationUri(),
true,
observer
)
fun registerObserver(observer: ContentObserver, context: Context) {
context.contentResolver.registerContentObserver(getConversationUri(), true, observer)
}
/**
@@ -862,10 +810,7 @@ object SMSHelper {
* ...
* ]
</String></String></String></Attachment> */
@JvmStatic
fun jsonArrayToAttachmentsList(
jsonArray: JSONArray?
): List<Attachment> {
fun jsonArrayToAttachmentsList(jsonArray: JSONArray?): List<Attachment> {
if (jsonArray == null) {
return emptyList()
}
@@ -887,11 +832,7 @@ object SMSHelper {
/**
* converts a given JSONArray into List<Address>
</Address> */
@JvmStatic
fun jsonArrayToAddressList(context: Context, jsonArray: JSONArray?): List<Address>? {
if (jsonArray == null) {
return null
}
fun jsonArrayToAddressList(context: Context, jsonArray: JSONArray): List<Address> {
val addresses: MutableList<Address> = ArrayList()
try {
for (i in 0 until jsonArray.length()) {
@@ -909,13 +850,9 @@ object SMSHelper {
* Represent an ID used to uniquely identify a message thread
*/
class ThreadID(val threadID: Long) {
override fun toString(): String {
return threadID.toString()
}
override fun toString(): String = threadID.toString()
override fun hashCode(): Int {
return java.lang.Long.hashCode(threadID)
}
override fun hashCode(): Int = java.lang.Long.hashCode(threadID)
override fun equals(other: Any?): Boolean {
return other!!.javaClass.isAssignableFrom(ThreadID::class.java) && (other as ThreadID?)!!.threadID == threadID
@@ -933,9 +870,9 @@ object SMSHelper {
class Attachment(
private val partID: Long,
@JvmField val mimeType: String,
@JvmField val base64EncodedFile: String?,
@JvmField val uniqueIdentifier: String
val mimeType: String,
val base64EncodedFile: String?,
val uniqueIdentifier: String
) {
@Throws(JSONException::class)
@@ -1020,7 +957,7 @@ object SMSHelper {
class Message internal constructor(
private val addresses: List<Address>,
val body: String,
@JvmField val date: Long,
val date: Long,
val type: Int,
val read: Int,
private val threadID: ThreadID,
@@ -1149,7 +1086,7 @@ object SMSHelper {
* Since this means a thread has to be spawned, this method might block until that thread is
* ready to serve requests
*/
@JvmStatic
@JvmStatic // required for ConnectivityReportPlugin.java
fun getLooper(): Looper? {
if (singleton == null) {
looperReadyLock.lock()

View File

@@ -6,6 +6,7 @@
package org.kde.kdeconnect.Helpers.SecurityHelpers;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Configuration;
@@ -62,7 +63,7 @@ import javax.security.auth.x500.X500Principal;
public class SslHelper {
public static Certificate certificate; //my device's certificate
private static CertificateFactory factory;
private static final CertificateFactory factory;
static {
try {
factory = CertificateFactory.getInstance("X.509");
@@ -71,19 +72,13 @@ public class SslHelper {
}
}
private final static TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() {
public java.security.cert.X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
@Override
public void checkClientTrusted(X509Certificate[] certs, String authType) {
}
@Override
public void checkServerTrusted(X509Certificate[] certs, String authType) {
}
@SuppressLint({"CustomX509TrustManager", "TrustAllX509TrustManager"})
private final static TrustManager[] trustAllCerts = new TrustManager[] {
new X509TrustManager() {
private final X509Certificate[] issuers = new X509Certificate[0];
@Override public X509Certificate[] getAcceptedIssuers() { return issuers; }
@Override public void checkClientTrusted(X509Certificate[] certs, String authType) { }
@Override public void checkServerTrusted(X509Certificate[] certs, String authType) { }
}
};
@@ -287,31 +282,4 @@ public class SslHelper {
return IETFUtils.valueToString(rdn.getFirst().getValue());
}
public static String getVerificationKey(Certificate certificateA, Certificate certificateB) {
try {
byte[] a = certificateA.getPublicKey().getEncoded();
byte[] b = certificateB.getPublicKey().getEncoded();
if (Arrays.compareUnsigned(a, b) < 0) {
// Swap them so on both devices they are in the same order
byte[] aux = a;
a = b;
b = aux;
}
byte[] concat = new byte[a.length + b.length];
System.arraycopy(a, 0, concat, 0, a.length);
System.arraycopy(b, 0, concat, a.length, b.length);
byte[] hash = MessageDigest.getInstance("SHA-256").digest(concat);
Formatter formatter = new Formatter();
for (byte value : hash) {
formatter.format("%02x", value);
}
return formatter.toString().substring(0,8).toUpperCase(Locale.ROOT);
} catch(Exception e) {
e.printStackTrace();
return "error";
}
}
}

View File

@@ -13,6 +13,9 @@ object VideoUrlsHelper {
private const val MINUTES_IN_HOUR = 60
private const val SECONDS_IN_HOUR = SECONDS_IN_MINUTE * MINUTES_IN_HOUR
/** PeerTube uses a Flickr Base58 encoded short UUID (alphanumeric, but 0, O, I, and l are excluded) with a length of 22 characters **/
private val peerTubePathPattern = Regex("^/w/[1-9a-km-zA-HJ-NP-Z]{22}(\\?.+)?$")
@Throws(MalformedURLException::class)
fun formatUriWithSeek(address: String, position: Long): URL {
val positionSeconds = position / 1000 // milliseconds to seconds
@@ -33,7 +36,10 @@ object VideoUrlsHelper {
url.editParameter("start", Regex("\\d+")) { "$positionSeconds" }
}
host.contains("twitch.tv") -> {
url.editParameter("t", Regex("(\\d+[hH])?(\\d+[mM])?\\d+[sS]")) { positionSeconds.formatTimestampHMS() }
url.editParameter("t", Regex("(\\d+[hH])?(\\d+[mM])?\\d+[sS]")) { formatTimestampHMS(positionSeconds) }
}
url.path.matches(peerTubePathPattern) -> {
url.editParameter("start", Regex("(\\d+[hH])?(\\d+[mM])?\\d+[sS]")) { formatTimestampHMS(positionSeconds) }
}
else -> url
}
@@ -62,13 +68,16 @@ object VideoUrlsHelper {
return URL("${urlBase}?${modifiedUrlQuery}") // -> "https://www.youtube.com/watch?v=ovX5G0O5ZvA&t=14"
}
/** Formats timestamp to e.g. 01h02m34s */
private fun Long.formatTimestampHMS(): String {
if (this == 0L) return "0s"
/**
* @param timestamp in seconds
* @return timestamp formatted as e.g. "01h02m34s"
* */
private fun formatTimestampHMS(timestamp: Long): String {
if (timestamp == 0L) return "0s"
val seconds: Long = this % SECONDS_IN_MINUTE
val minutes: Long = (this / SECONDS_IN_MINUTE) % MINUTES_IN_HOUR
val hours: Long = this / SECONDS_IN_HOUR
val seconds: Long = timestamp % SECONDS_IN_MINUTE
val minutes: Long = (timestamp / SECONDS_IN_MINUTE) % MINUTES_IN_HOUR
val hours: Long = timestamp / SECONDS_IN_HOUR
fun pad(s: String) = s.padStart(3, '0')
val hoursText = if (hours > 0) pad("${hours}h") else ""

View File

@@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2024 Mash Kyrielight <fiepi@live.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Helpers
import android.view.View
import org.kde.kdeconnect.extensions.setupBottomMargin
import org.kde.kdeconnect.extensions.setupBottomPadding
object WindowHelper {
// for java only
@JvmStatic
fun setupBottomPadding(view: View) {
view.setupBottomPadding()
}
// for java only
@JvmStatic
fun setupBottomMargin(view: View) {
view.setupBottomMargin()
}
}

View File

@@ -16,14 +16,12 @@ import java.net.Socket
import kotlin.concurrent.Volatile
class NetworkPacket private constructor(
val id: Long,
val type: String,
private val mBody: JSONObject,
var payload: Payload?,
var payloadTransferInfo: JSONObject,
) {
constructor(type: String) : this(
id = System.currentTimeMillis(),
type = type,
mBody = JSONObject(),
payload = null,
@@ -209,7 +207,7 @@ class NetworkPacket private constructor(
@Throws(JSONException::class)
fun serialize(): String {
val jo = JSONObject()
jo.put("id", id)
jo.put("id", System.currentTimeMillis())
jo.put("type", type)
jo.put("body", mBody)
if (hasPayload()) {
@@ -285,14 +283,13 @@ class NetworkPacket private constructor(
@Throws(JSONException::class)
fun unserialize(s: String): NetworkPacket {
val jo = JSONObject(s)
val id = jo.getLong("id")
val type = jo.getString("type")
val mBody = jo.getJSONObject("body")
val hasPayload = jo.has("payloadSize")
val payloadTransferInfo = if (hasPayload) jo.getJSONObject("payloadTransferInfo") else JSONObject()
val payload = if (hasPayload) Payload(jo.getLong("payloadSize")) else null
return NetworkPacket(id, type, mBody, payload, payloadTransferInfo)
return NetworkPacket(type, mBody, payload, payloadTransferInfo)
}
}
}

View File

@@ -8,7 +8,13 @@ package org.kde.kdeconnect
import android.util.Log
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.*
import org.bouncycastle.util.Arrays
import org.kde.kdeconnect.Helpers.SecurityHelpers.SslHelper
import org.kde.kdeconnect_tp.R
import java.security.MessageDigest
import java.security.cert.Certificate
import java.util.Formatter
import kotlin.math.abs
import kotlin.time.Duration.Companion.seconds
class PairingHandler(private val device: Device, private val callback: PairingCallback, var state: PairState) {
@@ -31,6 +37,7 @@ class PairingHandler(private val device: Device, private val callback: PairingCa
private val pairingJob = SupervisorJob()
private val pairingScope = CoroutineScope(Dispatchers.IO + pairingJob)
private var pairingTimestamp = 0L
fun packetReceived(np: NetworkPacket) {
cancelTimer()
@@ -50,10 +57,26 @@ class PairingHandler(private val device: Device, private val callback: PairingCa
Log.w("PairingHandler", "Received pairing request from a device we already trusted.")
// It would be nice to auto-accept the pairing request here, but since the pairing accept and pairing request
// messages are identical, this could create an infinite loop if both devices are "accepting" each other pairs.
// Instead, unpair and handle as if "NotPaired".
// Instead, unpair and handle as if "NotPaired". TODO: No longer true in protocol version 8
state = PairState.NotPaired
callback.unpaired()
}
if (device.protocolVersion >= 8) {
pairingTimestamp = np.getLong("timestamp", -1L)
if (pairingTimestamp == -1L) {
state = PairState.NotPaired
callback.unpaired()
return
}
val currentTimestamp = System.currentTimeMillis() / 1000L
if (abs(pairingTimestamp - currentTimestamp) > allowedTimestampDifferenceSeconds) {
state = PairState.NotPaired
callback.pairingFailed(device.context.getString(R.string.error_clocks_not_match))
return
}
}
state = PairState.RequestedByPeer
pairingScope.launch {
@@ -85,6 +108,18 @@ class PairingHandler(private val device: Device, private val callback: PairingCa
}
}
fun verificationKey(): String? {
return if (device.protocolVersion >= 8) {
if (state != PairState.Requested && state != PairState.RequestedByPeer) {
return null
} else {
getVerificationKey(SslHelper.certificate, device.certificate, pairingTimestamp)
}
} else {
getVerificationKeyV7(SslHelper.certificate, device.certificate)
}
}
fun requestPairing() {
cancelTimer()
@@ -126,6 +161,8 @@ class PairingHandler(private val device: Device, private val callback: PairingCa
}
val np = NetworkPacket(NetworkPacket.PACKET_TYPE_PAIR)
np["pair"] = true
pairingTimestamp = System.currentTimeMillis() / 1000L
np["timestamp"] = pairingTimestamp
device.sendPacket(np, statusCallback)
}
@@ -181,4 +218,36 @@ class PairingHandler(private val device: Device, private val callback: PairingCa
private fun cancelTimer() {
pairingJob.cancelChildren()
}
companion object {
private const val allowedTimestampDifferenceSeconds = 1_800 // 30 minutes
// Concatenate in a deterministic order so on both devices the result is the same
private fun sortedConcat(a: ByteArray, b: ByteArray): ByteArray {
return if (Arrays.compareUnsigned(a, b) < 0) {
b + a
} else {
a + b
}
}
private fun humanReadableHash(bytes: ByteArray): String {
val hash = MessageDigest.getInstance("SHA-256").digest(bytes)
val formatter = Formatter()
for (value in hash) {
formatter.format("%02x", value)
}
return formatter.toString().substring(0, 8).uppercase()
}
fun getVerificationKey(certificateA: Certificate, certificateB: Certificate, timestamp: Long): String {
val certsConcat = sortedConcat(certificateA.publicKey.encoded, certificateB.publicKey.encoded)
return humanReadableHash(certsConcat + timestamp.toString().toByteArray())
}
fun getVerificationKeyV7(certificateA: Certificate, certificateB: Certificate): String {
val certsConcat = sortedConcat(certificateA.publicKey.encoded, certificateB.publicKey.encoded)
return humanReadableHash(certsConcat)
}
}
}

View File

@@ -15,37 +15,46 @@ import android.speech.RecognizerIntent;
import android.speech.SpeechRecognizer;
import android.view.View;
import androidx.appcompat.app.AppCompatActivity;
import androidx.annotation.NonNull;
import org.kde.kdeconnect.KdeConnect;
import org.kde.kdeconnect.UserInterface.MainActivity;
import org.kde.kdeconnect.UserInterface.PermissionsAlertDialogFragment;
import org.kde.kdeconnect.base.BaseActivity;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.ActivityBigscreenBinding;
import java.util.ArrayList;
import java.util.Objects;
public class BigscreenActivity extends AppCompatActivity {
import kotlin.Lazy;
import kotlin.LazyKt;
public class BigscreenActivity extends BaseActivity<ActivityBigscreenBinding> {
private static final int REQUEST_SPEECH = 100;
private final Lazy<ActivityBigscreenBinding> lazyBinding = LazyKt.lazy(() -> ActivityBigscreenBinding.inflate(getLayoutInflater()));
@NonNull
@Override
protected ActivityBigscreenBinding getBinding() {
return lazyBinding.getValue();
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final ActivityBigscreenBinding binding = ActivityBigscreenBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbarLayout.toolbar);
setSupportActionBar(getBinding().toolbarLayout.toolbar);
Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setDisplayShowHomeEnabled(true);
final String deviceId = getIntent().getStringExtra("deviceId");
if (!SpeechRecognizer.isRecognitionAvailable(this)) {
binding.micButton.setEnabled(false);
binding.micButton.setVisibility(View.INVISIBLE);
getBinding().micButton.setEnabled(false);
getBinding().micButton.setVisibility(View.INVISIBLE);
}
BigscreenPlugin plugin = KdeConnect.getInstance().getDevicePlugin(deviceId, BigscreenPlugin.class);
@@ -54,13 +63,13 @@ public class BigscreenActivity extends AppCompatActivity {
return;
}
binding.leftButton.setOnClickListener(v -> plugin.sendLeft());
binding.rightButton.setOnClickListener(v -> plugin.sendRight());
binding.upButton.setOnClickListener(v -> plugin.sendUp());
binding.downButton.setOnClickListener(v -> plugin.sendDown());
binding.selectButton.setOnClickListener(v -> plugin.sendSelect());
binding.homeButton.setOnClickListener(v -> plugin.sendHome());
binding.micButton.setOnClickListener(v -> {
getBinding().leftButton.setOnClickListener(v -> plugin.sendLeft());
getBinding().rightButton.setOnClickListener(v -> plugin.sendRight());
getBinding().upButton.setOnClickListener(v -> plugin.sendUp());
getBinding().downButton.setOnClickListener(v -> plugin.sendDown());
getBinding().selectButton.setOnClickListener(v -> plugin.sendSelect());
getBinding().homeButton.setOnClickListener(v -> plugin.sendHome());
getBinding().micButton.setOnClickListener(v -> {
if (plugin.hasMicPermission()) {
activateSTT();
} else {

View File

@@ -10,26 +10,35 @@ import android.util.Log;
import android.view.Window;
import android.view.WindowManager;
import androidx.appcompat.app.AppCompatActivity;
import androidx.annotation.NonNull;
import org.kde.kdeconnect.KdeConnect;
import org.kde.kdeconnect.base.BaseActivity;
import org.kde.kdeconnect_tp.databinding.ActivityFindMyPhoneBinding;
import java.util.Objects;
public class FindMyPhoneActivity extends AppCompatActivity {
import kotlin.Lazy;
import kotlin.LazyKt;
public class FindMyPhoneActivity extends BaseActivity<ActivityFindMyPhoneBinding> {
static final String EXTRA_DEVICE_ID = "deviceId";
String deviceId;
private final Lazy<ActivityFindMyPhoneBinding> lazyBinding = LazyKt.lazy(() -> ActivityFindMyPhoneBinding.inflate(getLayoutInflater()));
@NonNull
@Override
protected ActivityFindMyPhoneBinding getBinding() {
return lazyBinding.getValue();
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final ActivityFindMyPhoneBinding binding = ActivityFindMyPhoneBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbarLayout.toolbar);
setSupportActionBar(getBinding().toolbarLayout.toolbar);
Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setDisplayShowHomeEnabled(true);
@@ -47,7 +56,7 @@ public class FindMyPhoneActivity extends AppCompatActivity {
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
);
binding.bFindMyPhone.setOnClickListener(view -> finish());
getBinding().bFindMyPhone.setOnClickListener(view -> finish());
}
@Override

View File

@@ -30,6 +30,7 @@ import org.kde.kdeconnect.UserInterface.compose.KdeTextButton
import org.kde.kdeconnect.UserInterface.compose.KdeTextField
import org.kde.kdeconnect.UserInterface.compose.KdeTheme
import org.kde.kdeconnect.UserInterface.compose.KdeTopAppBar
import org.kde.kdeconnect.extensions.safeDrawPadding
import org.kde.kdeconnect_tp.R
private const val INPUT_CACHE_KEY = "compose_send_input_cache"
@@ -91,6 +92,7 @@ class ComposeSendActivity : AppCompatActivity() {
private fun ComposeSendScreen() {
KdeTheme(this) {
Scaffold(
modifier = Modifier.safeDrawPadding(),
topBar = {
KdeTopAppBar(
title = stringResource(R.string.compose_send_title),

View File

@@ -13,7 +13,6 @@ import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.Bundle;
import android.util.Log;
import android.view.GestureDetector;
import android.view.HapticFeedbackConstants;
import android.view.Menu;
@@ -24,18 +23,23 @@ import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager;
import org.kde.kdeconnect.KdeConnect;
import org.kde.kdeconnect.UserInterface.PluginSettingsActivity;
import org.kde.kdeconnect.base.BaseActivity;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.ActivityMousepadBinding;
import java.util.Objects;
import kotlin.Lazy;
import kotlin.LazyKt;
public class MousePadActivity
extends AppCompatActivity
extends BaseActivity<ActivityMousepadBinding>
implements GestureDetector.OnGestureListener,
GestureDetector.OnDoubleTapListener,
MousePadGestureDetector.OnGestureListener,
@@ -74,6 +78,14 @@ public class MousePadActivity
private boolean prefsApplied = false;
private final Lazy<ActivityMousepadBinding> lazyBinding = LazyKt.lazy(() -> ActivityMousepadBinding.inflate(getLayoutInflater()));
@NonNull
@Override
protected ActivityMousepadBinding getBinding() {
return lazyBinding.getValue();
}
enum ClickType {
LEFT, RIGHT, MIDDLE, NONE;
@@ -131,15 +143,12 @@ public class MousePadActivity
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_mousepad);
setSupportActionBar(findViewById(R.id.toolbar));
setSupportActionBar(getBinding().toolbarLayout.toolbar);
Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setDisplayShowHomeEnabled(true);
findViewById(R.id.mouse_click_left).setOnClickListener(v -> sendLeftClick());
findViewById(R.id.mouse_click_middle).setOnClickListener(v -> sendMiddleClick());
findViewById(R.id.mouse_click_right).setOnClickListener(v -> sendRightClick());
getBinding().mouseClickLeft.setOnClickListener(v -> sendLeftClick());
getBinding().mouseClickMiddle.setOnClickListener(v -> sendMiddleClick());
getBinding().mouseClickRight.setOnClickListener(v -> sendRightClick());
deviceId = getIntent().getStringExtra("deviceId");
@@ -150,7 +159,7 @@ public class MousePadActivity
mDetector.setOnDoubleTapListener(this);
mSensorManager = ContextCompat.getSystemService(this, SensorManager.class);
keyListenerView = findViewById(R.id.keyListener);
keyListenerView = getBinding().keyListener;
keyListenerView.setDeviceId(deviceId);
prefs = PreferenceManager.getDefaultSharedPreferences(this);
@@ -582,9 +591,9 @@ public class MousePadActivity
}
if (prefs.getBoolean(getString(R.string.mousepad_mouse_buttons_enabled_pref), true)) {
findViewById(R.id.mouse_buttons).setVisibility(View.VISIBLE);
getBinding().mouseButtons.setVisibility(View.VISIBLE);
} else {
findViewById(R.id.mouse_buttons).setVisibility(View.GONE);
getBinding().mouseButtons.setVisibility(View.GONE);
}
doubleTapDragEnabled = prefs.getBoolean(getString(R.string.mousepad_doubletap_drag_enabled_pref), true);

View File

@@ -13,17 +13,18 @@ import android.os.Bundle;
import android.preference.PreferenceManager;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatActivity;
import org.kde.kdeconnect.BackgroundService;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.Helpers.SafeTextChecker;
import org.kde.kdeconnect.Helpers.WindowHelper;
import org.kde.kdeconnect.KdeConnect;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect.UserInterface.List.EntryItemWithIcon;
import org.kde.kdeconnect.UserInterface.List.ListAdapter;
import org.kde.kdeconnect.UserInterface.List.SectionItem;
import org.kde.kdeconnect.base.BaseActivity;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.ActivitySendkeystrokesBinding;
@@ -33,7 +34,10 @@ import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
public class SendKeystrokesToHostActivity extends AppCompatActivity {
import kotlin.Lazy;
import kotlin.LazyKt;
public class SendKeystrokesToHostActivity extends BaseActivity<ActivitySendkeystrokesBinding> {
// text with these length and content can be send without user confirmation.
// more or less chosen arbitrarily, so that we allow short PINS and TANS without interruption (if only one device is connected)
@@ -42,19 +46,30 @@ public class SendKeystrokesToHostActivity extends AppCompatActivity {
public static final String SAFE_CHARS = "1234567890";
private ActivitySendkeystrokesBinding binding;
private boolean contentIsOkay;
private final Lazy<ActivitySendkeystrokesBinding> lazyBinding = LazyKt.lazy(() -> ActivitySendkeystrokesBinding.inflate(getLayoutInflater()));
@NonNull
@Override
public ActivitySendkeystrokesBinding getBinding() {
return lazyBinding.getValue();
}
@Override
public boolean isScrollable() {
return true;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivitySendkeystrokesBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbarLayout.toolbar);
setSupportActionBar(getBinding().toolbarLayout.toolbar);
Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setDisplayShowHomeEnabled(true);
WindowHelper.setupBottomPadding(getBinding().devicesList);
}
@@ -73,7 +88,7 @@ public class SendKeystrokesToHostActivity extends AppCompatActivity {
if ("text/x-keystrokes".equals(type)) {
String toSend = intent.getStringExtra(Intent.EXTRA_TEXT);
binding.textToSend.setText(toSend);
getBinding().textToSend.setText(toSend);
// if the preference send_safe_text_immediately is true, we will check if exactly one
// device is connected and send the text to it without user confirmation, to make sending of
@@ -121,7 +136,7 @@ public class SendKeystrokesToHostActivity extends AppCompatActivity {
private void sendKeys(Device deviceId) {
String toSend;
if (binding.textToSend.getText() != null && (toSend = binding.textToSend.getText().toString().trim()).length() > 0) {
if (getBinding().textToSend.getText() != null && (toSend = getBinding().textToSend.getText().toString().trim()).length() > 0) {
final NetworkPacket np = new NetworkPacket(MousePadPlugin.PACKET_TYPE_MOUSEPAD_REQUEST);
np.set("key", toSend);
MousePadPlugin plugin = KdeConnect.getInstance().getDevicePlugin(deviceId.getDeviceId(), MousePadPlugin.class);
@@ -156,8 +171,8 @@ public class SendKeystrokesToHostActivity extends AppCompatActivity {
}
}
binding.devicesList.setAdapter(new ListAdapter(SendKeystrokesToHostActivity.this, items));
binding.devicesList.setOnItemClickListener((adapterView, view, i, l) -> {
getBinding().devicesList.setAdapter(new ListAdapter(SendKeystrokesToHostActivity.this, items));
getBinding().devicesList.setOnItemClickListener((adapterView, view, i, l) -> {
Device device = devicesList.get(i - 1); // NOTE: -1 because of the title!
sendKeys(device);
this.finish(); // close the activity

View File

@@ -121,8 +121,12 @@ public class MouseReceiverService extends AccessibilityService {
new Handler(instance.getMainLooper()).post(() -> {
// Log.i("MouseReceiverService", "performing move");
try {
instance.windowManager.updateViewLayout(instance.cursorView, instance.cursorLayout);
instance.cursorView.setVisibility(View.VISIBLE);
} catch (IllegalArgumentException e) {
e.printStackTrace();
}
});
}

View File

@@ -8,22 +8,25 @@ package org.kde.kdeconnect.Plugins.MprisPlugin
import android.os.Bundle
import android.view.KeyEvent
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.google.android.material.tabs.TabLayoutMediator
import org.kde.kdeconnect.Plugins.SystemVolumePlugin.SystemVolumeFragment
import org.kde.kdeconnect.base.BaseActivity
import org.kde.kdeconnect.extensions.viewBinding
import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.ActivityMprisBinding
class MprisActivity : AppCompatActivity() {
private lateinit var activityMprisBinding: ActivityMprisBinding
class MprisActivity : BaseActivity<ActivityMprisBinding>() {
override val binding: ActivityMprisBinding by viewBinding(ActivityMprisBinding::inflate)
private lateinit var mprisPagerAdapter: MprisPagerAdapter
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> {
val pagePosition = activityMprisBinding.mprisTabs.selectedTabPosition
val pagePosition = binding.mprisTabs.selectedTabPosition
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
mprisPagerAdapter.onVolumeUp(pagePosition)
} else {
@@ -46,17 +49,13 @@ class MprisActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activityMprisBinding = ActivityMprisBinding.inflate(layoutInflater)
setContentView(activityMprisBinding.root)
val deviceId = intent.getStringExtra(MprisPlugin.DEVICE_ID_KEY)
mprisPagerAdapter = MprisPagerAdapter(this, deviceId)
activityMprisBinding.mprisPager.adapter = mprisPagerAdapter
binding.mprisPager.adapter = mprisPagerAdapter
val tabLayoutMediator = TabLayoutMediator(
activityMprisBinding.mprisTabs, activityMprisBinding.mprisPager
binding.mprisTabs, binding.mprisPager
) { tab, position ->
tab.setText(
mprisPagerAdapter.getTitle(position)
@@ -65,7 +64,7 @@ class MprisActivity : AppCompatActivity() {
tabLayoutMediator.attach()
setSupportActionBar(activityMprisBinding.toolbar)
setSupportActionBar(binding.toolbar)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
}

View File

@@ -52,7 +52,7 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
private var spotifyRunning = false
// Holds the device and player displayed in the notification
private var notificationDevice: String? = null
private var notificationDeviceId: String? = null
private var notificationPlayer: MprisPlayer? = null
// Holds the device ids for which we can display a notification
@@ -64,29 +64,27 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
// Callback for control via the media session API
private val mediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() {
override fun onPlay() {
notificationPlayer!!.sendPlay()
notificationPlayer?.sendPlay()
}
override fun onPause() {
notificationPlayer!!.sendPause()
notificationPlayer?.sendPause()
}
override fun onSkipToNext() {
notificationPlayer!!.sendNext()
notificationPlayer?.sendNext()
}
override fun onSkipToPrevious() {
notificationPlayer!!.sendPrevious()
notificationPlayer?.sendPrevious()
}
override fun onStop() {
if (notificationPlayer != null) {
notificationPlayer!!.sendStop()
}
notificationPlayer?.sendStop()
}
override fun onSeekTo(pos: Long) {
notificationPlayer!!.sendSetPosition(pos.toInt())
notificationPlayer?.sendSetPosition(pos.toInt())
}
}
@@ -101,7 +99,7 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
* @param device The device id
*/
fun onCreate(context: Context?, plugin: MprisPlugin, device: String) {
if (mprisDevices.isEmpty()) {
if (mprisDevices.isEmpty) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.registerOnSharedPreferenceChangeListener(this)
}
@@ -139,7 +137,7 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
plugin.removePlayerListUpdatedHandler("media_notification")
updateMediaNotification()
if (mprisDevices.isEmpty()) {
if (mprisDevices.isEmpty) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.unregisterOnSharedPreferenceChangeListener(this)
}
@@ -153,25 +151,27 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
* player and device, while possible.
*/
private fun updateCurrentPlayer(): MprisPlayer? {
val player = findPlayer()
val player = findPlayer() ?: return null
// Update the last-displayed device and player
notificationDevice = if (player.first == null) null else player.first!!.deviceId
notificationDeviceId = if (player.first == null) null else player.first.deviceId
notificationPlayer = player.second
return notificationPlayer
}
private fun findPlayer(): Pair<Device?, MprisPlayer?> {
// First try the previously displayed player (if still playing) or the previous displayed device (otherwise)
if (notificationDevice != null && mprisDevices.contains(notificationDevice)) {
val device = KdeConnect.getInstance().getDevice(notificationDevice)
val player = if (notificationPlayer != null && notificationPlayer!!.isPlaying) {
getPlayerFromDevice(device, notificationPlayer)
private fun findPlayer(): Pair<Device, MprisPlayer>? {
val currentDevice = if (notificationDeviceId != null && mprisDevices.contains(notificationDeviceId)) {
KdeConnect.getInstance().getDevice(notificationDeviceId)
} else {
getPlayerFromDevice(device, null)
null
}
// First try the previously displayed player (if still playing) or the previous displayed device (otherwise)
if (currentDevice != null) {
val playingPlayer = notificationPlayer?.takeIf { it.isPlaying }
val player = getPlayerFromDevice(currentDevice, playingPlayer)
if (player != null) {
return Pair(device, player)
return Pair(currentDevice, player)
}
}
@@ -186,42 +186,33 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
// So no player is playing. Try the previously displayed player again
// This will succeed if it's paused:
// that allows pausing and subsequently resuming via the notification
if (notificationDevice != null && mprisDevices.contains(notificationDevice)) {
val device = KdeConnect.getInstance().getDevice(notificationDevice)
val player = getPlayerFromDevice(device, notificationPlayer)
if (currentDevice != null) {
val player = getPlayerFromDevice(currentDevice, notificationPlayer)
if (player != null) {
return Pair(device, player)
return Pair(currentDevice, player)
}
}
return Pair(null, null)
}
private fun getPlayerFromDevice(device: Device?, preferredPlayer: MprisPlayer?): MprisPlayer? {
if (device == null || !mprisDevices.contains(device.deviceId)) return null
val plugin = device.getPlugin(MprisPlugin::class.java) ?: return null
// First try the preferred player, if supplied
if (plugin.hasPlayer(preferredPlayer) && shouldShowPlayer(preferredPlayer)) {
return preferredPlayer
}
// Otherwise, accept any playing player
val player = plugin.playingPlayer
if (shouldShowPlayer(player)) {
return player
}
return null
}
private fun shouldShowPlayer(player: MprisPlayer?): Boolean {
return player != null && !(player.isSpotify && spotifyRunning)
private fun getPlayerFromDevice(device: Device, preferredPlayer: MprisPlayer?): MprisPlayer? {
if (!mprisDevices.contains(device.deviceId)) return null
val plugin = device.getPlugin(MprisPlugin::class.java) ?: return null
// First try the preferred player, if supplied & available, otherwise, accept any playing player
val player = preferredPlayer?.takeIf(plugin::hasPlayer)
?: plugin.playingPlayer
?: return null
return player.takeIf(::shouldShowPlayer)
}
private fun shouldShowPlayer(player: MprisPlayer): Boolean {
return !(player.isSpotify && spotifyRunning)
}
private fun updateRemoteDeviceVolumeControl() {
val plugin = KdeConnect.getInstance().getDevicePlugin(notificationDevice, SystemVolumePlugin::class.java)
val plugin = KdeConnect.getInstance().getDevicePlugin(notificationDeviceId, SystemVolumePlugin::class.java)
?: return
val systemVolumeProvider = fromPlugin(plugin)
systemVolumeProvider.addStateListener(this)
@@ -250,7 +241,7 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
// Make sure our information is up-to-date
val currentPlayer = updateCurrentPlayer()
val device = KdeConnect.getInstance().getDevice(notificationDevice)
val device = KdeConnect.getInstance().getDevice(notificationDeviceId)
if (device == null) {
closeMediaNotification()
return
@@ -295,7 +286,7 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
// Create all actions (previous/play/pause/next)
val iPlay = Intent(context, MprisMediaNotificationReceiver::class.java).apply {
setAction(MprisMediaNotificationReceiver.ACTION_PLAY)
putExtra(MprisMediaNotificationReceiver.EXTRA_DEVICE_ID, notificationDevice)
putExtra(MprisMediaNotificationReceiver.EXTRA_DEVICE_ID, notificationDeviceId)
putExtra(MprisMediaNotificationReceiver.EXTRA_MPRIS_PLAYER, currentPlayer.playerName)
}
val piPlay = PendingIntent.getBroadcast(
@@ -310,7 +301,7 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
val iPause = Intent(context, MprisMediaNotificationReceiver::class.java).apply {
setAction(MprisMediaNotificationReceiver.ACTION_PAUSE)
putExtra(MprisMediaNotificationReceiver.EXTRA_DEVICE_ID, notificationDevice)
putExtra(MprisMediaNotificationReceiver.EXTRA_DEVICE_ID, notificationDeviceId)
putExtra(MprisMediaNotificationReceiver.EXTRA_MPRIS_PLAYER, currentPlayer.playerName)
}
val piPause = PendingIntent.getBroadcast(
@@ -325,7 +316,7 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
val iPrevious = Intent(context, MprisMediaNotificationReceiver::class.java).apply {
setAction(MprisMediaNotificationReceiver.ACTION_PREVIOUS)
putExtra(MprisMediaNotificationReceiver.EXTRA_DEVICE_ID, notificationDevice)
putExtra(MprisMediaNotificationReceiver.EXTRA_DEVICE_ID, notificationDeviceId)
putExtra(MprisMediaNotificationReceiver.EXTRA_MPRIS_PLAYER, currentPlayer.playerName)
}
val piPrevious = PendingIntent.getBroadcast(
@@ -340,7 +331,7 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
val iNext = Intent(context, MprisMediaNotificationReceiver::class.java).apply {
setAction(MprisMediaNotificationReceiver.ACTION_NEXT)
putExtra(MprisMediaNotificationReceiver.EXTRA_DEVICE_ID, notificationDevice)
putExtra(MprisMediaNotificationReceiver.EXTRA_DEVICE_ID, notificationDeviceId)
putExtra(MprisMediaNotificationReceiver.EXTRA_MPRIS_PLAYER, currentPlayer.playerName)
}
val piNext = PendingIntent.getBroadcast(
@@ -354,7 +345,7 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
)
val iOpenActivity = Intent(context, MprisActivity::class.java).apply {
putExtra("deviceId", notificationDevice)
putExtra("deviceId", notificationDeviceId)
putExtra("player", currentPlayer.playerName)
}
@@ -392,7 +383,7 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
if (!currentPlayer.isPlaying) {
val iCloseNotification = Intent(context, MprisMediaNotificationReceiver::class.java)
iCloseNotification.setAction(MprisMediaNotificationReceiver.ACTION_CLOSE_NOTIFICATION)
iCloseNotification.putExtra(MprisMediaNotificationReceiver.EXTRA_DEVICE_ID, notificationDevice)
iCloseNotification.putExtra(MprisMediaNotificationReceiver.EXTRA_DEVICE_ID, notificationDeviceId)
iCloseNotification.putExtra(MprisMediaNotificationReceiver.EXTRA_MPRIS_PLAYER, currentPlayer.playerName)
val piCloseNotification = PendingIntent.getBroadcast(
context,
@@ -450,17 +441,18 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
// Display the notification
synchronized(instance) {
if (mediaSession == null) {
mediaSession = MediaSessionCompat(context!!, MPRIS_MEDIA_SESSION_TAG)
mediaSession!!.setCallback(mediaSessionCallback, Handler(context!!.mainLooper))
val mediaSession = mediaSession ?: MediaSessionCompat(context!!, MPRIS_MEDIA_SESSION_TAG).apply {
setCallback(mediaSessionCallback, Handler(context!!.mainLooper))
}
mediaSession!!.setMetadata(metadata.build())
mediaSession!!.setPlaybackState(playbackState.build())
mediaStyle.setMediaSession(mediaSession!!.sessionToken)
mediaSession.setMetadata(metadata.build())
mediaSession.setPlaybackState(playbackState.build())
mediaStyle.setMediaSession(mediaSession.sessionToken)
notification.setStyle(mediaStyle)
mediaSession!!.isActive = true
val nm = ContextCompat.getSystemService(context!!, NotificationManager::class.java)
nm!!.notify(MPRIS_MEDIA_NOTIFICATION_ID, notification.build())
mediaSession.isActive = true
ContextCompat.getSystemService(context!!, NotificationManager::class.java)?.notify(MPRIS_MEDIA_NOTIFICATION_ID, notification.build())
if (this.mediaSession == null) {
this.mediaSession = mediaSession
}
}
}
@@ -472,16 +464,14 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
// Clear the current player and media session
notificationPlayer = null
synchronized(instance) {
if (mediaSession != null) {
mediaSession!!.setPlaybackState(PlaybackStateCompat.Builder().build())
mediaSession!!.setMetadata(MediaMetadataCompat.Builder().build())
mediaSession!!.isActive = false
mediaSession!!.release()
mediaSession = null
val currentProvider = currentProvider
currentProvider?.release()
mediaSession?.apply {
setPlaybackState(PlaybackStateCompat.Builder().build())
setMetadata(MediaMetadataCompat.Builder().build())
isActive = false
release()
}
mediaSession = null
currentProvider?.release()
}
}
@@ -529,8 +519,8 @@ class MprisMediaSession : OnSharedPreferenceChangeListener, NotificationReceiver
}
}
private fun StatusBarNotification?.isSpotify(): Boolean =
this?.packageName == SPOTIFY_PACKAGE_NAME
private fun StatusBarNotification.isSpotify(): Boolean =
this.packageName == SPOTIFY_PACKAGE_NAME
companion object {
const val TAG = "MprisMediaSession"

View File

@@ -55,24 +55,23 @@ class MprisNowPlayingFragment : Fragment(), VolumeKeyListener {
val activityIntent = requireActivity().intent
targetPlayerName = if (activityIntent.hasExtra("player")) {
activityIntent.getStringExtra("player")!!.also {
val stringExtra = activityIntent.getStringExtra("player")
if (stringExtra != null) {
activityIntent.removeExtra("player")
}
} else {
savedInstanceState?.getString("targetPlayer") ?: "".also {
targetPlayerName = stringExtra
?: savedInstanceState?.getString("targetPlayer")
?: "".also {
Log.i("MprisNowPlayingFragment", "No `targetPlayer` specified in savedInstanceState")
}
}
connectToPlugin()
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
val interval_time_str = prefs.getString(
val intervalTime = prefs.getString(
getString(R.string.mpris_time_key),
getString(R.string.mpris_time_default)
)
val interval_time = interval_time_str!!.toInt()
)!!.toInt()
performActionOnClick(mprisControlBinding.loopButton) { p: MprisPlayer ->
when (p.loopStatus) {
@@ -92,11 +91,11 @@ class MprisNowPlayingFragment : Fragment(), VolumeKeyListener {
performActionOnClick(
mprisControlBinding.rewButton
) { p -> p.sendSeek(interval_time * -1) }
) { p -> p.sendSeek(intervalTime * -1) }
performActionOnClick(
mprisControlBinding.ffButton
) { p -> p.sendSeek(interval_time) }
) { p -> p.sendSeek(intervalTime) }
performActionOnClick(mprisControlBinding.nextButton, MprisPlayer::sendNext)
@@ -116,8 +115,8 @@ class MprisNowPlayingFragment : Fragment(), VolumeKeyListener {
positionSeekUpdateRunnable = Runnable {
if (!isAdded) return@Runnable // Fragment was already detached
if (targetPlayer != null) {
mprisControlBinding.positionSeek.progress = targetPlayer!!.position.toInt()
targetPlayer?.let {
mprisControlBinding.positionSeek.progress = it.position.toInt()
}
positionSeekUpdateHandler.removeCallbacks(positionSeekUpdateRunnable)
positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 1000)
@@ -134,9 +133,7 @@ class MprisNowPlayingFragment : Fragment(), VolumeKeyListener {
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
if (targetPlayer != null) {
targetPlayer!!.sendSetPosition(seekBar.progress)
}
targetPlayer?.sendSetPosition(seekBar.progress)
positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 200)
}
})
@@ -199,20 +196,20 @@ class MprisNowPlayingFragment : Fragment(), VolumeKeyListener {
mprisControlBinding.playerSpinner.onItemSelectedListener =
object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(arg0: AdapterView<*>?, arg1: View?, pos: Int, id: Long) {
if (pos >= playerList.size) return
val player = playerList[pos]
if (targetPlayer != null && player == targetPlayer!!.playerName) {
if (player == targetPlayer?.playerName) {
return //Player hasn't actually changed
}
targetPlayer = plugin.getPlayerStatus(player)
if (targetPlayer != null) {
targetPlayerName = targetPlayer!!.playerName
targetPlayer = plugin.getPlayerStatus(player)?.also {
targetPlayerName = it.playerName
}
updatePlayerStatus(plugin)
if (targetPlayer != null && targetPlayer!!.isPlaying) {
if (targetPlayer?.isPlaying == true) {
MprisMediaSession.instance.playerSelected(targetPlayer)
}
}
@@ -227,8 +224,8 @@ class MprisNowPlayingFragment : Fragment(), VolumeKeyListener {
targetPlayer = plugin.playingPlayer
}
//Try to select the specified player
if (targetPlayer != null) {
val targetIndex = adapter.getPosition(targetPlayer!!.playerName)
targetPlayer?.let {
val targetIndex = adapter.getPosition(it.playerName)
if (targetIndex >= 0) {
mprisControlBinding.playerSpinner.setSelection(targetIndex)
} else {
@@ -275,8 +272,7 @@ class MprisNowPlayingFragment : Fragment(), VolumeKeyListener {
val albumArt = playerStatus.getAlbumArt()
if (albumArt == null) {
val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_album_art_placeholder)!!
val placeholder_art = DrawableCompat.wrap(drawable)
activityMprisBinding.albumArt.setImageDrawable(placeholder_art)
activityMprisBinding.albumArt.setImageDrawable(DrawableCompat.wrap(drawable))
} else {
activityMprisBinding.albumArt.setImageBitmap(albumArt)
}
@@ -345,12 +341,12 @@ class MprisNowPlayingFragment : Fragment(), VolumeKeyListener {
* @param step step size volume change
*/
private fun updateVolume(step: Int) {
if (targetPlayer == null) return
val targetPlayer = targetPlayer ?: return
val newVolume = calculateNewVolume(targetPlayer!!.volume, DEFAULT_MAX_VOLUME, step)
val newVolume = calculateNewVolume(targetPlayer.volume, DEFAULT_MAX_VOLUME, step)
if (targetPlayer!!.volume != newVolume) {
targetPlayer!!.sendSetVolume(newVolume)
if (targetPlayer.volume != newVolume) {
targetPlayer.sendSetVolume(newVolume)
}
}
@@ -364,18 +360,19 @@ class MprisNowPlayingFragment : Fragment(), VolumeKeyListener {
override fun onPrepareOptionsMenu(menu: Menu) {
menu.clear()
if (targetPlayer != null && "" != targetPlayer!!.url) {
if (!targetPlayer?.url.isNullOrEmpty()) {
menu.add(0, MENU_OPEN_URL, Menu.NONE, R.string.mpris_open_url)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val targetPlayer = targetPlayer
if (targetPlayer != null && item.itemId == MENU_OPEN_URL) {
try {
val url = VideoUrlsHelper.formatUriWithSeek(targetPlayer!!.url, targetPlayer!!.position).toString()
val url = VideoUrlsHelper.formatUriWithSeek(targetPlayer.url, targetPlayer.position).toString()
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(browserIntent)
targetPlayer!!.sendPause()
targetPlayer.sendPause()
return true
} catch (e: MalformedURLException) {
e.printStackTrace()

View File

@@ -176,14 +176,10 @@ class MprisPlugin : Plugin() {
}
}
fun interface Callback {
fun callback()
}
private val players = ConcurrentHashMap<String, MprisPlayer>()
private var supportAlbumArtPayload = false
private val playerStatusUpdated = ConcurrentHashMap<String, Callback>()
private val playerListUpdated = ConcurrentHashMap<String, Callback>()
private val playerStatusUpdated = ConcurrentHashMap<String, () -> Unit>()
private val playerListUpdated = ConcurrentHashMap<String, () -> Unit>()
override val displayName: String
get() = context.resources.getString(R.string.pref_plugin_mpris)
@@ -285,14 +281,7 @@ class MprisPlugin : Plugin() {
playerStatus.albumArtUrl = ""
}
for (key in playerStatusUpdated.keys) {
try {
playerStatusUpdated[key]!!.callback()
} catch (e: Exception) {
Log.e("MprisControl", "Exception", e)
playerStatusUpdated.remove(key)
}
}
notifyPlayerStatusUpdated()
// Check to see if a stream has stopped playing and we should deliver a notification
if (np.has("isPlaying") && !playerStatus.isPlaying && wasPlaying) {
@@ -331,14 +320,7 @@ class MprisPlugin : Plugin() {
}
}
if (!equals) {
playerListUpdated.forEach { (key, callback) ->
runCatching {
callback.callback()
}.onFailure {
Log.e("MprisControl", "Exception", it)
playerListUpdated.remove(key)
}
}
notifyPlayerListUpdated()
}
}
@@ -377,25 +359,46 @@ class MprisPlugin : Plugin() {
override val outgoingPacketTypes: Array<String> = arrayOf(PACKET_TYPE_MPRIS_REQUEST)
fun setPlayerStatusUpdatedHandler(id: String, h: Callback) {
playerStatusUpdated[id] = h
h.callback()
fun setPlayerStatusUpdatedHandler(id: String, callback: () -> Unit) {
playerStatusUpdated[id] = callback
callback()
}
fun removePlayerStatusUpdatedHandler(id: String) {
playerStatusUpdated.remove(id)
}
fun setPlayerListUpdatedHandler(id: String, h: Callback) {
playerListUpdated[id] = h
fun notifyPlayerStatusUpdated() {
for ((key, callback) in playerStatusUpdated) {
try {
callback()
} catch(e: Exception) {
Log.e("MprisControl", "Exception", e)
playerStatusUpdated.remove(key)
}
}
}
h.callback()
fun setPlayerListUpdatedHandler(id: String, callback: () -> Unit) {
playerListUpdated[id] = callback
callback()
}
fun removePlayerListUpdatedHandler(id: String) {
playerListUpdated.remove(id)
}
fun notifyPlayerListUpdated() {
for ((key, callback) in playerListUpdated) {
try {
callback()
} catch(e: Exception) {
Log.e("MprisControl", "Exception", e)
playerListUpdated.remove(key)
}
}
}
val playerList: List<String>
get() = players.keys.sorted()
@@ -413,7 +416,7 @@ class MprisPlugin : Plugin() {
*/
get() = players.values.stream().filter(MprisPlayer::isPlaying).findFirst().orElse(null)
fun hasPlayer(player: MprisPlayer?): Boolean = player != null && players.containsValue(player)
fun hasPlayer(player: MprisPlayer): Boolean = players.containsValue(player)
private fun requestPlayerList() {
val np = NetworkPacket(PACKET_TYPE_MPRIS_REQUEST).apply {
@@ -445,14 +448,7 @@ class MprisPlugin : Plugin() {
fun fetchedAlbumArt(url: String) {
if (players.values.stream().anyMatch { player -> url == player.albumArtUrl }) {
playerStatusUpdated.forEach { (key, callback) ->
runCatching {
callback.callback()
}.onFailure {
Log.e("MprisControl", "Exception", it)
playerStatusUpdated.remove(key)
}
}
notifyPlayerStatusUpdated()
}
}

View File

@@ -3,14 +3,9 @@
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.MprisPlugin
package org.kde.kdeconnect.Plugins.MprisPlugin;
public interface VolumeKeyListener {
void onVolumeUp();
void onVolumeDown();
interface VolumeKeyListener {
fun onVolumeUp()
fun onVolumeDown()
}

View File

@@ -280,8 +280,10 @@ public class MprisReceiverPlugin extends Plugin {
np.set("canSeek", player.canSeek());
np.set("volume", player.getVolume());
MprisReceiverCallback cb = playerCbs.get(player.getName());
assert cb != null;
String artUrl = cb.getArtUrl();
String artUrl = null;
if (cb != null) {
artUrl = cb.getArtUrl();
}
if (artUrl != null) {
np.set("albumArtUrl", artUrl);
Log.v(TAG, "Sending metadata with url " + artUrl);

View File

@@ -9,6 +9,8 @@ package org.kde.kdeconnect.Plugins.NotificationsPlugin;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.LauncherActivityInfo;
import android.content.pm.LauncherApps;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.graphics.Bitmap;
@@ -16,6 +18,10 @@ import android.graphics.Canvas;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.Process;
import android.os.UserHandle;
import android.os.UserManager;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
@@ -27,25 +33,30 @@ import android.widget.CheckBox;
import android.widget.CheckedTextView;
import android.widget.ListView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.SearchView;
import androidx.core.widget.TextViewCompat;
import com.google.android.material.materialswitch.MaterialSwitch;
import org.kde.kdeconnect.Helpers.ThreadHelper;
import org.kde.kdeconnect.base.BaseActivity;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.ActivityNotificationFilterBinding;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import kotlin.Lazy;
import kotlin.LazyKt;
//TODO: Turn this into a PluginSettingsFragment
public class NotificationFilterActivity extends AppCompatActivity {
private ActivityNotificationFilterBinding binding;
public class NotificationFilterActivity extends BaseActivity<ActivityNotificationFilterBinding> {
private AppDatabase appDatabase;
private String prefKey;
@@ -61,6 +72,14 @@ public class NotificationFilterActivity extends AppCompatActivity {
private List<AppListInfo> mAllApps;
private List<AppListInfo> apps; // Filtered data.
private final Lazy<ActivityNotificationFilterBinding> lazyBinding = LazyKt.lazy(() -> ActivityNotificationFilterBinding.inflate(getLayoutInflater()));
@NonNull
@Override
protected ActivityNotificationFilterBinding getBinding() {
return lazyBinding.getValue();
}
class AppListAdapter extends BaseAdapter {
@Override
@@ -87,13 +106,13 @@ public class NotificationFilterActivity extends AppCompatActivity {
if (position == 0) {
checkedTextView.setText(R.string.all);
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(checkedTextView, null, null, null, null);
binding.lvFilterApps.setItemChecked(position, appDatabase.getAllEnabled());
getBinding().lvFilterApps.setItemChecked(position, appDatabase.getAllEnabled());
} else {
final AppListInfo info = apps.get(position - 1);
checkedTextView.setText(info.name);
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(checkedTextView, info.icon, null, null, null);
checkedTextView.setCompoundDrawablePadding((int) (8 * getResources().getDisplayMetrics().density));
binding.lvFilterApps.setItemChecked(position, info.isEnabled);
getBinding().lvFilterApps.setItemChecked(position, info.isEnabled);
}
return view;
@@ -105,14 +124,12 @@ public class NotificationFilterActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityNotificationFilterBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
appDatabase = new AppDatabase(NotificationFilterActivity.this, false);
if (getIntent()!= null){
prefKey = getIntent().getStringExtra(NotificationsPlugin.getPrefKey());
}
setSupportActionBar(binding.toolbarLayout.toolbar);
setSupportActionBar(getBinding().toolbarLayout.toolbar);
Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setDisplayShowHomeEnabled(true);
SharedPreferences preferences = this.getSharedPreferences(prefKey, Context.MODE_PRIVATE);
@@ -124,17 +141,56 @@ public class NotificationFilterActivity extends AppCompatActivity {
List<ApplicationInfo> appList = packageManager.getInstalledApplications(0);
int count = appList.size();
AppListInfo[] allApps = new AppListInfo[count];
final Set<String> allPackageNames = new HashSet<>(count);
final List<AppListInfo> allApps = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
ApplicationInfo appInfo = appList.get(i);
allApps[i] = new AppListInfo();
allApps[i].pkg = appInfo.packageName;
allApps[i].name = appInfo.loadLabel(packageManager).toString();
allApps[i].icon = resizeIcon(appInfo.loadIcon(packageManager), 48);
allApps[i].isEnabled = appDatabase.isEnabled(appInfo.packageName);
final ApplicationInfo appInfo = appList.get(i);
AppListInfo appListInfo = new AppListInfo();
appListInfo.pkg = appInfo.packageName;
appListInfo.name = appInfo.loadLabel(packageManager).toString();
appListInfo.icon = resizeIcon(appInfo.loadIcon(packageManager), 48);
appListInfo.isEnabled = appDatabase.isEnabled(appInfo.packageName);
allApps.add(appListInfo);
allPackageNames.add(appInfo.packageName);
}
Arrays.sort(allApps, (lhs, rhs) -> lhs.name.compareToIgnoreCase(rhs.name));
mAllApps = Arrays.asList(allApps);
// Find apps from work profile
try {
final UserHandle currentUser = Process.myUserHandle();
final LauncherApps launcher = (LauncherApps) getSystemService(Context.LAUNCHER_APPS_SERVICE);
final UserManager um = (UserManager) getSystemService(Context.USER_SERVICE);
final List<UserHandle> userProfiles = um.getUserProfiles();
for (final UserHandle userProfile : userProfiles) {
if (userProfile.equals(currentUser)) {
continue;
}
final List<LauncherActivityInfo> userActivityList = launcher.getActivityList(null, userProfile);
for (final LauncherActivityInfo app : userActivityList) {
if (allPackageNames.contains(app.getApplicationInfo().packageName)) {
continue;
}
final ApplicationInfo appInfo = app.getApplicationInfo();
AppListInfo appListInfo = new AppListInfo();
appListInfo.pkg = appInfo.packageName;
appListInfo.name = appInfo.loadLabel(packageManager).toString();
appListInfo.icon = resizeIcon(appInfo.loadIcon(packageManager), 48);
appListInfo.isEnabled = appDatabase.isEnabled(appInfo.packageName);
allApps.add(appListInfo);
allPackageNames.add(app.getApplicationInfo().packageName);
}
}
} catch (final Exception e) {
Log.e("NotificationFilterActiv", "Failed to get apps from work profile", e);
}
allApps.sort((lhs, rhs) -> lhs.name.compareToIgnoreCase(rhs.name));
mAllApps = allApps;
apps = new ArrayList<>(mAllApps);
runOnUiThread(this::displayAppList);
});
@@ -152,7 +208,7 @@ public class NotificationFilterActivity extends AppCompatActivity {
}
private void displayAppList() {
final ListView listView = binding.lvFilterApps;
final ListView listView = getBinding().lvFilterApps;
AppListAdapter adapter = new AppListAdapter();
listView.setAdapter(adapter);
listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
@@ -233,7 +289,7 @@ public class NotificationFilterActivity extends AppCompatActivity {
}
listView.setVisibility(View.VISIBLE);
binding.spinner.setVisibility(View.GONE);
getBinding().spinner.setVisibility(View.GONE);
}
private Drawable resizeIcon(Drawable icon, int maxSize) {
@@ -284,7 +340,7 @@ public class NotificationFilterActivity extends AppCompatActivity {
}
}
((AppListAdapter) binding.lvFilterApps.getAdapter()).notifyDataSetChanged();
((AppListAdapter) getBinding().lvFilterApps.getAdapter()).notifyDataSetChanged();
return true;
}
});

View File

@@ -8,17 +8,17 @@ package org.kde.kdeconnect.Plugins
import android.content.Context
import android.util.Log
import androidx.annotation.DrawableRes
import org.atteo.classindex.ClassIndex
import org.atteo.classindex.IndexAnnotated
import org.kde.kdeconnect.Device
object PluginFactory {
annotation class LoadablePlugin //Annotate plugins with this so PluginFactory finds them
private var pluginInfo: Map<String, PluginInfo> = mapOf()
fun initPluginInfo(context: Context) {
try {
val plugins = ClassIndex.getAnnotated(LoadablePlugin::class.java)
.map { it.newInstance() as Plugin }
val plugins = com.albertvaka.classindexksp.LoadablePlugin
.map { it.java.getDeclaredConstructor().newInstance() as Plugin }
.map { plugin -> plugin.apply { setContext(context, null) } }
pluginInfo = plugins.associate { plugin -> Pair(plugin.pluginKey, PluginInfo(plugin)) }
@@ -45,7 +45,7 @@ object PluginFactory {
fun instantiatePluginForDevice(context: Context, pluginKey: String, device: Device): Plugin? {
try {
val plugin = pluginInfo[pluginKey]?.instantiableClass?.newInstance()?.apply { setContext(context, device) }
val plugin = pluginInfo[pluginKey]?.instantiableClass?.getDeclaredConstructor()?.newInstance()?.apply { setContext(context, device) }
return plugin
} catch (e: Exception) {
Log.e("PluginFactory", "Could not instantiate plugin: $pluginKey", e)
@@ -67,9 +67,6 @@ object PluginFactory {
return used.map { it.key }.toSet()
}
@IndexAnnotated
annotation class LoadablePlugin //Annotate plugins with this so PluginFactory finds them
class PluginInfo private constructor(
val displayName: String,
val description: String,

View File

@@ -37,6 +37,7 @@ import org.kde.kdeconnect.KdeConnect
import org.kde.kdeconnect.UserInterface.compose.KdeButton
import org.kde.kdeconnect.UserInterface.compose.KdeTheme
import org.kde.kdeconnect.UserInterface.compose.KdeTopAppBar
import org.kde.kdeconnect.extensions.safeDrawPadding
import org.kde.kdeconnect_tp.R
private const val VOLUME_UP = 1
@@ -69,7 +70,11 @@ class PresenterActivity : AppCompatActivity(), SensorEventListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
plugin = KdeConnect.getInstance().getDevicePlugin(intent.getStringExtra("deviceId"), PresenterPlugin::class.java)!!
plugin = KdeConnect.getInstance().getDevicePlugin(intent.getStringExtra("deviceId"), PresenterPlugin::class.java)
?: run {
finish()
return
}
setContent { PresenterScreen() }
createMediaSession()
}
@@ -116,7 +121,10 @@ class PresenterActivity : AppCompatActivity(), SensorEventListener {
val sensorManager = LocalContext.current.getSystemService(SENSOR_SERVICE) as? SensorManager
KdeTheme(this) {
Scaffold(topBar = { PresenterAppBar() }) {
Scaffold(
modifier = Modifier.safeDrawPadding(),
topBar = { PresenterAppBar() }
) {
Column(
modifier = Modifier.fillMaxSize().padding(it).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
@@ -126,6 +134,7 @@ class PresenterActivity : AppCompatActivity(), SensorEventListener {
modifier = Modifier.padding(bottom = 8.dp).padding(horizontal = 16.dp),
style = MaterialTheme.typography.bodyLarge,
)
@Suppress("DEPRECATION") // we explicitly want the non-mirrored version of the icons
Row(
modifier = Modifier.fillMaxSize().weight(3f),
horizontalArrangement = Arrangement.spacedBy(20.dp),

View File

@@ -17,8 +17,8 @@ import android.view.View;
import android.widget.AdapterView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import org.json.JSONException;
@@ -26,6 +26,7 @@ import org.json.JSONObject;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.KdeConnect;
import org.kde.kdeconnect.UserInterface.List.ListAdapter;
import org.kde.kdeconnect.base.BaseActivity;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.ActivityRunCommandBinding;
@@ -35,8 +36,19 @@ import java.util.Comparator;
import java.util.List;
import java.util.Objects;
public class RunCommandActivity extends AppCompatActivity {
private ActivityRunCommandBinding binding;
import kotlin.Lazy;
import kotlin.LazyKt;
public class RunCommandActivity extends BaseActivity<ActivityRunCommandBinding> {
private final Lazy<ActivityRunCommandBinding> lazyBinding = LazyKt.lazy(() -> ActivityRunCommandBinding.inflate(getLayoutInflater()));
@NonNull
@Override
protected ActivityRunCommandBinding getBinding() {
return lazyBinding.getValue();
}
private String deviceId;
private final RunCommandPlugin.CommandsChangedCallback commandsChangedCallback = () -> runOnUiThread(this::updateView);
private List<CommandEntry> commandItems;
@@ -48,7 +60,7 @@ public class RunCommandActivity extends AppCompatActivity {
return;
}
registerForContextMenu(binding.runCommandsList);
registerForContextMenu(getBinding().runCommandsList);
commandItems = new ArrayList<>();
for (JSONObject obj : plugin.getCommandList()) {
@@ -63,26 +75,23 @@ public class RunCommandActivity extends AppCompatActivity {
ListAdapter adapter = new ListAdapter(RunCommandActivity.this, commandItems);
binding.runCommandsList.setAdapter(adapter);
binding.runCommandsList.setOnItemClickListener((adapterView, view1, i, l) ->
getBinding().runCommandsList.setAdapter(adapter);
getBinding().runCommandsList.setOnItemClickListener((adapterView, view1, i, l) ->
plugin.runCommand(commandItems.get(i).getKey()));
String text = getString(R.string.addcommand_explanation);
if (!plugin.canAddCommand()) {
text += "\n" + getString(R.string.addcommand_explanation2);
}
binding.addCommandExplanation.setText(text);
binding.addCommandExplanation.setVisibility(commandItems.isEmpty() ? View.VISIBLE : View.GONE);
getBinding().addCommandExplanation.setText(text);
getBinding().addCommandExplanation.setVisibility(commandItems.isEmpty() ? View.VISIBLE : View.GONE);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityRunCommandBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbarLayout.toolbar);
setSupportActionBar(getBinding().toolbarLayout.toolbar);
Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setDisplayShowHomeEnabled(true);
@@ -93,11 +102,11 @@ public class RunCommandActivity extends AppCompatActivity {
RunCommandPlugin plugin = device.getPlugin(RunCommandPlugin.class);
if (plugin != null) {
if (plugin.canAddCommand()) {
binding.addCommandButton.show();
getBinding().addCommandButton.show();
} else {
binding.addCommandButton.hide();
getBinding().addCommandButton.hide();
}
binding.addCommandButton.setOnClickListener(v -> {
getBinding().addCommandButton.setOnClickListener(v -> {
plugin.sendSetupPacket();
new AlertDialog.Builder(RunCommandActivity.this)
.setTitle(R.string.add_command)

View File

@@ -1,28 +0,0 @@
/*
* SPDX-FileCopyrightText: 2020 Aniket Kumar <anikketkumar786@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SMSPlugin;
public final class MimeType {
public static final String TYPE_TEXT = "text/plain";
public static final String TYPE_IMAGE = "image";
public static final String TYPE_VIDEO = "video";
public static final String TYPE_AUDIO = "audio";
public static boolean isTypeText(String mimeType) { return mimeType.startsWith(TYPE_TEXT); }
public static boolean isTypeImage(String mimeType) {
return mimeType.startsWith(TYPE_IMAGE);
}
public static boolean isTypeVideo(String mimeType) { return mimeType.startsWith(TYPE_VIDEO); }
public static boolean isTypeAudio(String mimeType) { return mimeType.startsWith(TYPE_AUDIO); }
public static String postfixOf(String mimeType) { return mimeType.substring(mimeType.lastIndexOf('/')+1); }
}

View File

@@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2020 Aniket Kumar <anikketkumar786@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SMSPlugin
object MimeType {
const val TYPE_TEXT: String = "text/plain"
const val TYPE_IMAGE: String = "image"
const val TYPE_VIDEO: String = "video"
const val TYPE_AUDIO: String = "audio"
fun isTypeText(mimeType: String): Boolean {
return mimeType.startsWith(TYPE_TEXT)
}
fun isTypeImage(mimeType: String): Boolean {
return mimeType.startsWith(TYPE_IMAGE)
}
fun isTypeVideo(mimeType: String): Boolean {
return mimeType.startsWith(TYPE_VIDEO)
}
fun isTypeAudio(mimeType: String): Boolean {
return mimeType.startsWith(TYPE_AUDIO)
}
fun postfixOf(mimeType: String): String {
return mimeType.substring(mimeType.lastIndexOf('/') + 1)
}
}

View File

@@ -1,629 +0,0 @@
/*
* SPDX-FileCopyrightText: 2014 Albert Vaca Cintora <albertvaka@gmail.com>
* SPDX-FileCopyrightText: 2021 Simon Redman <simon@ergotech.com>
* SPDX-FileCopyrightText: 2020 Aniket Kumar <anikketkumar786@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SMSPlugin;
import static androidx.core.content.ContextCompat.RECEIVER_EXPORTED;
import static org.kde.kdeconnect.Plugins.TelephonyPlugin.TelephonyPlugin.PACKET_TYPE_TELEPHONY;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.database.ContentObserver;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.preference.PreferenceManager;
import android.provider.Telephony;
import android.telephony.PhoneNumberUtils;
import android.telephony.SmsManager;
import android.telephony.SmsMessage;
import androidx.annotation.WorkerThread;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import com.klinker.android.logger.Log;
import com.klinker.android.send_message.Transaction;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.kde.kdeconnect.Helpers.ContactsHelper;
import org.kde.kdeconnect.Helpers.SMSHelper;
import org.kde.kdeconnect.Helpers.ThreadHelper;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect.Plugins.Plugin;
import org.kde.kdeconnect.Plugins.PluginFactory;
import org.kde.kdeconnect.Plugins.TelephonyPlugin.TelephonyPlugin;
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment;
import org.kde.kdeconnect_tp.BuildConfig;
import org.kde.kdeconnect_tp.R;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import kotlin.sequences.Sequence;
@PluginFactory.LoadablePlugin
@SuppressLint("InlinedApi")
public class SMSPlugin extends Plugin {
/**
* Packet used to indicate a batch of messages has been pushed from the remote device
* <p>
* The body should contain the key "messages" mapping to an array of messages
* <p>
* For example:
* {
* "version": 2 // This is the second version of this packet type and
* // version 1 packets (which did not carry this flag)
* // are incompatible with the new format
* "messages" : [
* { "event" : 1, // 32-bit field containing a bitwise-or of event flags
* // See constants declared in SMSHelper.Message for defined
* // values and explanations
* "body" : "Hello", // Text message body
* "addresses": <List<Address>> // List of Address objects, one for each participant of the conversation
* // The user's Address is excluded so:
* // If this is a single-target messsage, there will only be one
* // Address (the other party)
* // If this is an incoming multi-target message, the first Address is the
* // sender and all other addresses are other parties to the conversation
* // If this is an outgoing multi-target message, the sender is implicit
* // (the user's phone number) and all Addresses are recipients
* "date" : "1518846484880", // Timestamp of the message
* "type" : "2", // Compare with Android's
* // Telephony.TextBasedSmsColumns.MESSAGE_TYPE_*
* "thread_id" : 132 // Thread to which the message belongs
* "read" : true // Boolean representing whether a message is read or unread
* },
* { ... },
* ...
* ]
*
* The following optional fields of a message object may be defined
* "sub_id": <int> // Android's subscriber ID, which is basically used to determine which SIM card the message
* // belongs to. This is mostly useful when attempting to reply to an SMS with the correct
* // SIM card using PACKET_TYPE_SMS_REQUEST.
* // If this value is not defined or if it does not match a valid subscriber_id known by
* // Android, we will use whatever subscriber ID Android gives us as the default
*
* "attachments": <List<Attachment>> // List of Attachment objects, one for each attached file in the message.
*
* An Attachment object looks like:
* {
* "part_id": <long> // part_id of the attachment used to read the file from MMS database
* "mime_type": <String> // contains the mime type of the file (eg: image/jpg, video/mp4 etc.)
* "encoded_thumbnail": <String> // Optional base64-encoded thumbnail preview of the content for types which support it
* "unique_identifier": <String> // Unique name of te file
* }
*
* An Address object looks like:
* {
* "address": <String> // Address (phone number, email address, etc.) of this object
* }
*/
private final static String PACKET_TYPE_SMS_MESSAGE = "kdeconnect.sms.messages";
private final static int SMS_MESSAGE_PACKET_VERSION = 2; // We *send* packets of this version
/**
* Packet sent to request a message be sent
*
* The body should look like so:
* {
* "version": 2, // The version of the packet being sent. Compare to SMS_REQUEST_PACKET_VERSION before attempting to handle.
* "sendSms": true, // (Depreciated, ignored) Old versions of the desktop app used to mix phone calls, SMS, etc. in the same packet type and used this field to differentiate.
* "phoneNumber": "542904563213", // (Depreciated) Retained for backwards-compatibility. Old versions of the desktop app send a single phoneNumber. Use the Addresses field instead.
* "addresses": <List of Addresses>, // The one or many targets of this message
* "messageBody": "Hi mom!", // Plain-text string to be sent as the body of the message (Optional if sending an attachment)
* "attachments": <List of Attached files>,
* "sub_id": 3859358340534 // Some magic number which tells Android which SIM card to use (Optional, if omitted, sends with the default SIM card)
* }
*
* An AttachmentContainer object looks like:
* {
* "fileName": <String> // Name of the file
* "base64EncodedFile": <String> // Base64 encoded file
* "mimeType": <String> // File type (eg: image/jpg, video/mp4 etc.)
* }
*/
private final static String PACKET_TYPE_SMS_REQUEST = "kdeconnect.sms.request";
private final static int SMS_REQUEST_PACKET_VERSION = 2; // We *handle* packets of this version or lower. Update this number only if future packets break backwards-compatibility.
/**
* Packet sent to request the most-recent message in each conversations on the device
* <p>
* The request packet shall contain no body
*/
private final static String PACKET_TYPE_SMS_REQUEST_CONVERSATIONS = "kdeconnect.sms.request_conversations";
/**
* Packet sent to request all the messages in a particular conversation
* <p>
* The following fields are available:
* "threadID": <long> // (Required) ThreadID to request
* "rangeStartTimestamp": <long> // (Optional) Millisecond epoch timestamp indicating the start of the range from which to return messages
* "numberToRequest": <long> // (Optional) Number of messages to return, starting from rangeStartTimestamp.
* // May return fewer than expected if there are not enough or more than expected if many
* // messages have the same timestamp.
*/
private final static String PACKET_TYPE_SMS_REQUEST_CONVERSATION = "kdeconnect.sms.request_conversation";
/**
* Packet sent to request an attachment file in a particular message of a conversation
* <p>
* The body should look like so:
* "part_id": <long> // Part id of the attachment
* "unique_identifier": <String> // This unique_identifier should come from a previous message packet's attachment field
*/
private final static String PACKET_TYPE_SMS_REQUEST_ATTACHMENT = "kdeconnect.sms.request_attachment";
/**
* Packet used to send original attachment file from mms database to desktop
* <p>
* The following fields are available:
* "filename": <String> // Name of the attachment file in the database
* "payload": // Actual attachment file to be transferred
*/
private final static String PACKET_TYPE_SMS_ATTACHMENT_FILE = "kdeconnect.sms.attachment_file";
private static final String KEY_PREF_BLOCKED_NUMBERS = "telephony_blocked_numbers";
private final BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
//Log.e("TelephonyPlugin","Telephony event: " + action);
if (Telephony.Sms.Intents.SMS_RECEIVED_ACTION.equals(action)) {
final Bundle bundle = intent.getExtras();
if (bundle == null) return;
final Object[] pdus = (Object[]) bundle.get("pdus");
ArrayList<SmsMessage> messages = new ArrayList<>();
for (Object pdu : pdus) {
// I hope, but am not sure, that the pdus array is in the order that the parts
// of the SMS message should be
// If it is not, I believe the pdu contains the information necessary to put it
// in order, but in my testing the order seems to be correct, so I won't worry
// about it now.
messages.add(SmsMessage.createFromPdu((byte[]) pdu));
}
smsBroadcastReceivedDeprecated(messages);
}
}
};
/**
* Keep track of the most-recently-seen message so that we can query for later ones as they arrive
*/
private long mostRecentTimestamp = 0;
// Since the mostRecentTimestamp is accessed both from the plugin's thread and the ContentObserver
// thread, make sure that access is coherent
private final Lock mostRecentTimestampLock = new ReentrantLock();
/**
* Keep track of whether we have received any packet which requested messages.
*
* If not, we will not send updates, since probably the user doesn't care.
*/
private boolean haveMessagesBeenRequested = false;
private class MessageContentObserver extends ContentObserver {
/**
* Create a ContentObserver to watch the Messages database. onChange is called for
* every subscribed change
*
* @param handler Handler object used to make the callback
*/
MessageContentObserver(Handler handler) {
super(handler);
}
/**
* The onChange method is called whenever the subscribed-to database changes
*
* In this case, this onChange expects to be called whenever *anything* in the Messages
* database changes and simply reports those updated messages to anyone who might be listening
*/
@Override
public void onChange(boolean selfChange) {
sendLatestMessage();
}
}
/**
* This receiver will be invoked only when the app will be set as the default sms app
* Whenever the app will be set as the default, the database update alert will be sent
* using messageUpdateReceiver and not the contentObserver class
*/
private final BroadcastReceiver messagesUpdateReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (Transaction.REFRESH.equals(action)) {
sendLatestMessage();
}
}
};
/**
* Helper method to read the latest message from the sms-mms database and sends it to the desktop
*
* Should only be called after initializing the mostRecentTimestamp
*/
private void sendLatestMessage() {
// Lock so no one uses the mostRecentTimestamp between the moment we read it and the
// moment we update it. This is because reading the Messages DB can take long.
mostRecentTimestampLock.lock();
if (!haveMessagesBeenRequested) {
// Since the user has not requested a message, there is most likely nobody listening
// for message updates, so just drop them rather than spending battery/time sending
// updates that don't matter.
mostRecentTimestampLock.unlock();
return;
}
List<SMSHelper.Message> messages = SMSHelper.getMessagesInRange(context, null, mostRecentTimestamp, null, false);
long newMostRecentTimestamp = mostRecentTimestamp;
for (SMSHelper.Message message : messages) {
if (message == null || message.date >= newMostRecentTimestamp) {
newMostRecentTimestamp = message.date;
}
}
// Update the most recent counter
mostRecentTimestamp = newMostRecentTimestamp;
mostRecentTimestampLock.unlock();
// Send the alert about the update
getDevice().sendPacket(constructBulkMessagePacket(messages));
}
/**
* Deliver an old-style SMS packet in response to a new message arriving
*
* For backwards-compatibility with long-lived distro packages, this method needs to exist in
* order to support older desktop apps. However, note that it should no longer be used
*
* This comment is being written 30 August 2018. Distros will likely be running old versions for
* many years to come...
*
* @param messages Ordered list of parts of the message body which should be combined into a single message
*/
@Deprecated
private void smsBroadcastReceivedDeprecated(ArrayList<SmsMessage> messages) {
if (BuildConfig.DEBUG) {
if (!(messages.size() > 0)) {
throw new AssertionError("This method requires at least one message");
}
}
NetworkPacket np = new NetworkPacket(PACKET_TYPE_TELEPHONY);
np.set("event", "sms");
StringBuilder messageBody = new StringBuilder();
for (int index = 0; index < messages.size(); index++) {
messageBody.append(messages.get(index).getMessageBody());
}
np.set("messageBody", messageBody.toString());
String phoneNumber = messages.get(0).getOriginatingAddress();
if (isNumberBlocked(phoneNumber))
return;
int permissionCheck = ContextCompat.checkSelfPermission(context,
Manifest.permission.READ_CONTACTS);
if (permissionCheck == PackageManager.PERMISSION_GRANTED) {
Map<String, String> contactInfo = ContactsHelper.phoneNumberLookup(context, phoneNumber);
if (contactInfo.containsKey("name")) {
np.set("contactName", contactInfo.get("name"));
}
if (contactInfo.containsKey("photoID")) {
np.set("phoneThumbnail", ContactsHelper.photoId64Encoded(context, contactInfo.get("photoID")));
}
}
if (phoneNumber != null) {
np.set("phoneNumber", phoneNumber);
}
getDevice().sendPacket(np);
}
@Override
public int getPermissionExplanation() {
return R.string.telepathy_permission_explanation;
}
@Override
public boolean onCreate() {
IntentFilter filter = new IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION);
filter.setPriority(500);
context.registerReceiver(receiver, filter);
IntentFilter refreshFilter = new IntentFilter(Transaction.REFRESH);
refreshFilter.setPriority(500);
context.registerReceiver(messagesUpdateReceiver, refreshFilter, RECEIVER_EXPORTED);
Looper helperLooper = SMSHelper.MessageLooper.getLooper();
ContentObserver messageObserver = new MessageContentObserver(new Handler(helperLooper));
SMSHelper.registerObserver(messageObserver, context);
// To see debug messages for Klinker library, uncomment the below line
//Log.setDebug(true);
mostRecentTimestampLock.lock();
mostRecentTimestamp = SMSHelper.getNewestMessageTimestamp(context);
mostRecentTimestampLock.unlock();
return true;
}
@Override
public @NonNull String getDisplayName() {
return context.getResources().getString(R.string.pref_plugin_telepathy);
}
@Override
public @NonNull String getDescription() {
return context.getResources().getString(R.string.pref_plugin_telepathy_desc);
}
@Override
public boolean onPacketReceived(@NonNull NetworkPacket np) {
long subID;
switch (np.getType()) {
case PACKET_TYPE_SMS_REQUEST_CONVERSATIONS:
Runnable handleRequestAllConversationsRunnable = () -> this.handleRequestAllConversations(np);
ThreadHelper.execute(handleRequestAllConversationsRunnable);
return true;
case PACKET_TYPE_SMS_REQUEST_CONVERSATION:
Runnable handleRequestSingleConversationRunnable = () -> this.handleRequestSingleConversation(np);
ThreadHelper.execute(handleRequestSingleConversationRunnable);
return true;
case PACKET_TYPE_SMS_REQUEST:
String textMessage = np.getString("messageBody");
subID = np.getLong("subID", -1);
List<SMSHelper.Address> addressList = SMSHelper.jsonArrayToAddressList(context, np.getJSONArray("addresses"));
if (addressList == null) {
// If the List of Address is null, then the SMS_REQUEST packet is
// most probably from the older version of the desktop app.
addressList = new ArrayList<>();
addressList.add(new SMSHelper.Address(context, np.getString("phoneNumber")));
}
List<SMSHelper.Attachment> attachedFiles = SMSHelper.jsonArrayToAttachmentsList(np.getJSONArray("attachments"));
SmsMmsUtils.sendMessage(context, textMessage, attachedFiles, addressList, (int) subID);
break;
case TelephonyPlugin.PACKET_TYPE_TELEPHONY_REQUEST:
if (np.getBoolean("sendSms")) {
String phoneNo = np.getString("phoneNumber");
String sms = np.getString("messageBody");
subID = np.getLong("subID", -1);
try {
SmsManager smsManager = subID == -1? SmsManager.getDefault() :
SmsManager.getSmsManagerForSubscriptionId((int) subID);
ArrayList<String> parts = smsManager.divideMessage(sms);
// If this message turns out to fit in a single SMS, sendMultipartTextMessage
// properly handles that case
smsManager.sendMultipartTextMessage(phoneNo, null, parts, null, null);
//TODO: Notify other end
} catch (Exception e) {
//TODO: Notify other end
Log.e("SMSPlugin", "Exception", e);
}
}
break;
case PACKET_TYPE_SMS_REQUEST_ATTACHMENT:
long partID = np.getLong("part_id");
String uniqueIdentifier = np.getString("unique_identifier");
NetworkPacket networkPacket = SmsMmsUtils.partIdToMessageAttachmentPacket(
context,
partID,
uniqueIdentifier,
PACKET_TYPE_SMS_ATTACHMENT_FILE
);
if (networkPacket != null) {
getDevice().sendPacket(networkPacket);
}
break;
}
return true;
}
/**
* Construct a proper packet of PACKET_TYPE_SMS_MESSAGE from the passed messages
*
* @param messages Messages to include in the packet
* @return NetworkPacket of type PACKET_TYPE_SMS_MESSAGE
*/
private static NetworkPacket constructBulkMessagePacket(Iterable<SMSHelper.Message> messages) {
NetworkPacket reply = new NetworkPacket(PACKET_TYPE_SMS_MESSAGE);
JSONArray body = new JSONArray();
for (SMSHelper.Message message : messages) {
try {
JSONObject json = message.toJSONObject();
body.put(json);
} catch (JSONException e) {
Log.e("Conversations", "Error serializing message", e);
}
}
reply.set("messages", body);
reply.set("version", SMS_MESSAGE_PACKET_VERSION);
return reply;
}
/**
* Respond to a request for all conversations
* <p>
* Send one packet of type PACKET_TYPE_SMS_MESSAGE with the first message in all conversations
*/
@WorkerThread
private boolean handleRequestAllConversations(NetworkPacket packet) {
haveMessagesBeenRequested = true;
Iterator<SMSHelper.Message> conversations = SMSHelper.getConversations(this.context).iterator();
while (conversations.hasNext()) {
SMSHelper.Message message = conversations.next();
NetworkPacket partialReply = constructBulkMessagePacket(Collections.singleton(message));
getDevice().sendPacket(partialReply);
}
return true;
}
@WorkerThread
private boolean handleRequestSingleConversation(NetworkPacket packet) {
haveMessagesBeenRequested = true;
SMSHelper.ThreadID threadID = new SMSHelper.ThreadID(packet.getLong("threadID"));
long rangeStartTimestamp = packet.getLong("rangeStartTimestamp", -1);
Long numberToGet = packet.getLong("numberToRequest", -1);
if (numberToGet < 0) {
numberToGet = null;
}
List<SMSHelper.Message> conversation;
if (rangeStartTimestamp < 0) {
conversation = SMSHelper.getMessagesInThread(this.context, threadID, numberToGet);
} else {
conversation = SMSHelper.getMessagesInRange(this.context, threadID, rangeStartTimestamp, numberToGet, true);
}
NetworkPacket reply = constructBulkMessagePacket(conversation);
getDevice().sendPacket(reply);
return true;
}
private boolean isNumberBlocked(String number) {
SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(context);
String[] blockedNumbers = sharedPref.getString(KEY_PREF_BLOCKED_NUMBERS, "").split("\n");
for (String s : blockedNumbers) {
if (PhoneNumberUtils.compare(number, s))
return true;
}
return false;
}
@Override
public boolean hasSettings() {
return true;
}
@Override
public PluginSettingsFragment getSettingsFragment(Activity activity) {
return PluginSettingsFragment.newInstance(getPluginKey(), R.xml.smsplugin_preferences);
}
@Override
public @NonNull String[] getSupportedPacketTypes() {
return new String[]{
PACKET_TYPE_SMS_REQUEST,
TelephonyPlugin.PACKET_TYPE_TELEPHONY_REQUEST,
PACKET_TYPE_SMS_REQUEST_CONVERSATIONS,
PACKET_TYPE_SMS_REQUEST_CONVERSATION,
PACKET_TYPE_SMS_REQUEST_ATTACHMENT
};
}
@Override
public @NonNull String[] getOutgoingPacketTypes() {
return new String[]{
PACKET_TYPE_SMS_MESSAGE,
PACKET_TYPE_SMS_ATTACHMENT_FILE
};
}
@Override
public @NonNull String[] getRequiredPermissions() {
return new String[]{
Manifest.permission.SEND_SMS,
Manifest.permission.READ_SMS,
// READ_PHONE_STATE should be optional, since we can just query the user, but that
// requires a GUI implementation for querying the user!
Manifest.permission.READ_PHONE_STATE,
};
}
/**
* Permissions required for sending and receiving MMs messages
*/
public static String[] getMmsPermissions() {
return new String[]{
Manifest.permission.RECEIVE_SMS,
Manifest.permission.RECEIVE_MMS,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.CHANGE_NETWORK_STATE,
Manifest.permission.WAKE_LOCK,
};
}
/**
* With versions older than KITKAT, lots of the content providers used in SMSHelper become
* un-documented. Most manufacturers *did* do things the same way as was done in mainline
* Android at that time, but some did not. If the manufacturer followed the default route,
* everything will be fine. If not, the plugin will crash. But, since we have a global catch-all
* in Device.onPacketReceived, it will not crash catastrophically.
* The onCreated method of this SMSPlugin complains if a version older than KitKat is loaded,
* but it still allowed in the optimistic hope that things will "just work"
*/
@Override
public int getMinSdk() {
return Build.VERSION_CODES.FROYO;
}
}

View File

@@ -0,0 +1,580 @@
/*
* SPDX-FileCopyrightText: 2014 Albert Vaca Cintora <albertvaka@gmail.com>
* SPDX-FileCopyrightText: 2021 Simon Redman <simon@ergotech.com>
* SPDX-FileCopyrightText: 2020 Aniket Kumar <anikketkumar786@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SMSPlugin
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.preference.PreferenceManager
import android.provider.Telephony
import android.telephony.PhoneNumberUtils
import android.telephony.SmsManager
import android.telephony.SmsMessage
import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat
import com.klinker.android.logger.Log
import com.klinker.android.send_message.Transaction
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.kde.kdeconnect.Helpers.ContactsHelper
import org.kde.kdeconnect.Helpers.SMSHelper
import org.kde.kdeconnect.Helpers.SMSHelper.MessageLooper.Companion.getLooper
import org.kde.kdeconnect.Helpers.SMSHelper.ThreadID
import org.kde.kdeconnect.Helpers.SMSHelper.getConversations
import org.kde.kdeconnect.Helpers.SMSHelper.getMessagesInRange
import org.kde.kdeconnect.Helpers.SMSHelper.getMessagesInThread
import org.kde.kdeconnect.Helpers.SMSHelper.getNewestMessageTimestamp
import org.kde.kdeconnect.Helpers.SMSHelper.jsonArrayToAddressList
import org.kde.kdeconnect.Helpers.SMSHelper.jsonArrayToAttachmentsList
import org.kde.kdeconnect.Helpers.SMSHelper.registerObserver
import org.kde.kdeconnect.Helpers.ThreadHelper.execute
import org.kde.kdeconnect.NetworkPacket
import org.kde.kdeconnect.Plugins.Plugin
import org.kde.kdeconnect.Plugins.PluginFactory.LoadablePlugin
import org.kde.kdeconnect.Plugins.SMSPlugin.SmsMmsUtils.partIdToMessageAttachmentPacket
import org.kde.kdeconnect.Plugins.SMSPlugin.SmsMmsUtils.sendMessage
import org.kde.kdeconnect.Plugins.TelephonyPlugin.TelephonyPlugin
import org.kde.kdeconnect.UserInterface.PluginSettingsFragment
import org.kde.kdeconnect_tp.BuildConfig
import org.kde.kdeconnect_tp.R
import java.util.concurrent.locks.Lock
import java.util.concurrent.locks.ReentrantLock
@LoadablePlugin
@SuppressLint("InlinedApi")
class SMSPlugin : Plugin() {
private val receiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action: String? = intent.action
//Log.e("TelephonyPlugin","Telephony event: " + action)
if (Telephony.Sms.Intents.SMS_RECEIVED_ACTION == action) {
val bundle: Bundle = intent.extras ?: return
val pdus: Array<Any>? = bundle.get("pdus") as Array<Any>?
val messages: MutableList<SmsMessage> = mutableListOf()
for (pdu: Any? in pdus!!) {
// I hope, but am not sure, that the pdus array is in the order that the parts
// of the SMS message should be
// If it is not, I believe the pdu contains the information necessary to put it
// in order, but in my testing the order seems to be correct, so I won't worry
// about it now.
messages.add(SmsMessage.createFromPdu(pdu as ByteArray?))
}
smsBroadcastReceivedDeprecated(messages)
}
}
}
/**
* Keep track of the most-recently-seen message so that we can query for later ones as they arrive
*/
private var mostRecentTimestamp: Long = 0
// Since the mostRecentTimestamp is accessed both from the plugin's thread and the ContentObserver
// thread, make sure that access is coherent
private val mostRecentTimestampLock: Lock = ReentrantLock()
/**
* Keep track of whether we have received any packet which requested messages.
*
* If not, we will not send updates, since probably the user doesn't care.
*/
private var haveMessagesBeenRequested: Boolean = false
private inner class MessageContentObserver
/**
* Create a ContentObserver to watch the Messages database. onChange is called for
* every subscribed change
*
* @param handler Handler object used to make the callback
*/
(handler: Handler?) : ContentObserver(handler) {
/**
* The onChange method is called whenever the subscribed-to database changes
*
* In this case, this onChange expects to be called whenever *anything* in the Messages
* database changes and simply reports those updated messages to anyone who might be listening
*/
override fun onChange(selfChange: Boolean) {
sendLatestMessage()
}
}
/**
* This receiver will be invoked only when the app will be set as the default sms app
* Whenever the app will be set as the default, the database update alert will be sent
* using messageUpdateReceiver and not the contentObserver class
*/
private val messagesUpdateReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action: String? = intent.action
if (Transaction.REFRESH == action) {
sendLatestMessage()
}
}
}
/**
* Helper method to read the latest message from the sms-mms database and sends it to the desktop
*
* Should only be called after initializing the mostRecentTimestamp
*/
private fun sendLatestMessage() {
// Lock so no one uses the mostRecentTimestamp between the moment we read it and the
// moment we update it. This is because reading the Messages DB can take long.
mostRecentTimestampLock.lock()
if (!haveMessagesBeenRequested) {
// Since the user has not requested a message, there is most likely nobody listening
// for message updates, so just drop them rather than spending battery/time sending
// updates that don't matter.
mostRecentTimestampLock.unlock()
return
}
val messages: List<SMSHelper.Message> = getMessagesInRange(context, null, mostRecentTimestamp, null, false)
var newMostRecentTimestamp: Long = mostRecentTimestamp
for (message: SMSHelper.Message? in messages) {
if (message == null || message.date >= newMostRecentTimestamp) {
newMostRecentTimestamp = message!!.date
}
}
// Update the most recent counter
mostRecentTimestamp = newMostRecentTimestamp
mostRecentTimestampLock.unlock()
// Send the alert about the update
device.sendPacket(constructBulkMessagePacket(messages))
}
/**
* Deliver an old-style SMS packet in response to a new message arriving
*
* For backwards-compatibility with long-lived distro packages, this method needs to exist in
* order to support older desktop apps. However, note that it should no longer be used
*
* This comment is being written 30 August 2018. Distros will likely be running old versions for many years to come...
*
* @param messages Ordered list of parts of the message body which should be combined into a single message
*/
@Deprecated("")
private fun smsBroadcastReceivedDeprecated(messages: MutableList<SmsMessage>) {
if (BuildConfig.DEBUG) {
if (messages.size <= 0) {
throw AssertionError("This method requires at least one message")
}
}
val np = NetworkPacket(TelephonyPlugin.PACKET_TYPE_TELEPHONY)
np["event"] = "sms"
np["messageBody"] = buildString {
for (message in messages) {
append(message.messageBody)
}
}
val phoneNumber: String? = messages[0].originatingAddress
if (isNumberBlocked(phoneNumber)) return
val permissionCheck: Int = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS)
if (permissionCheck == PackageManager.PERMISSION_GRANTED) {
val contactInfo: Map<String, String> = ContactsHelper.phoneNumberLookup(context, phoneNumber)
if (contactInfo.containsKey("name")) {
np["contactName"] = contactInfo["name"]
}
if (contactInfo.containsKey("photoID")) {
np["phoneThumbnail"] = ContactsHelper.photoId64Encoded(context, contactInfo["photoID"])
}
}
if (phoneNumber != null) {
np["phoneNumber"] = phoneNumber
}
device.sendPacket(np)
}
override val permissionExplanation: Int
get() = R.string.telepathy_permission_explanation
override fun onCreate(): Boolean {
val filter: IntentFilter = IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION)
filter.priority = 500
context.registerReceiver(receiver, filter)
val refreshFilter: IntentFilter = IntentFilter(Transaction.REFRESH)
refreshFilter.priority = 500
context.registerReceiver(messagesUpdateReceiver, refreshFilter, ContextCompat.RECEIVER_EXPORTED)
val helperLooper: Looper? = getLooper()
val messageObserver: ContentObserver = MessageContentObserver(Handler(helperLooper!!))
registerObserver(messageObserver, context)
// To see debug messages for Klinker library, uncomment the below line
//Log.setDebug(true)
mostRecentTimestampLock.lock()
mostRecentTimestamp = getNewestMessageTimestamp(context)
mostRecentTimestampLock.unlock()
return true
}
override val displayName: String
get() = context.resources.getString(R.string.pref_plugin_telepathy)
override val description: String
get() = context.resources.getString(R.string.pref_plugin_telepathy_desc)
override fun onPacketReceived(np: NetworkPacket): Boolean = when (np.type) {
PACKET_TYPE_SMS_REQUEST_CONVERSATIONS -> {
execute {
this.handleRequestAllConversations(np)
}
true
}
PACKET_TYPE_SMS_REQUEST_CONVERSATION -> {
execute {
this.handleRequestSingleConversation(np)
}
true
}
PACKET_TYPE_SMS_REQUEST -> {
val textMessage: String = np.getString("messageBody")
val subID = np.getLong("subID", -1)
val jsonAddressList = np.getJSONArray("addresses")
val addressList: List<SMSHelper.Address>
if (jsonAddressList == null) {
// If jsonAddressList is null, then the SMS_REQUEST packet is most probably from the older version of the desktop app.
addressList = listOf(SMSHelper.Address(context, np.getString("phoneNumber")))
}
else {
addressList = jsonArrayToAddressList(context, jsonAddressList)
}
val attachedFiles: List<SMSHelper.Attachment> = jsonArrayToAttachmentsList(np.getJSONArray("attachments"))
sendMessage(context, textMessage, attachedFiles, addressList.toMutableList(), subID.toInt())
true
}
TelephonyPlugin.PACKET_TYPE_TELEPHONY_REQUEST -> {
if (np.getBoolean("sendSms")) {
val phoneNo: String = np.getString("phoneNumber")
val sms: String = np.getString("messageBody")
val subID = np.getLong("subID", -1)
try {
val smsManager: SmsManager = if (subID == -1L) SmsManager.getDefault() else SmsManager.getSmsManagerForSubscriptionId(subID.toInt())
val parts: ArrayList<String> = smsManager.divideMessage(sms)
// If this message turns out to fit in a single SMS, sendMultipartTextMessage properly handles that case
smsManager.sendMultipartTextMessage(phoneNo, null, parts, null, null)
//TODO: Notify other end
} catch (e: Exception) {
//TODO: Notify other end
Log.e("SMSPlugin", "Exception", e)
}
}
true
}
PACKET_TYPE_SMS_REQUEST_ATTACHMENT -> {
val partID: Long = np.getLong("part_id")
val uniqueIdentifier: String = np.getString("unique_identifier")
val networkPacket: NetworkPacket? = partIdToMessageAttachmentPacket(context, partID, uniqueIdentifier, PACKET_TYPE_SMS_ATTACHMENT_FILE)
if (networkPacket != null) {
device.sendPacket(networkPacket)
}
true
}
else -> true
}
/**
* Respond to a request for all conversations
*
* @param packet One packet of type [PACKET_TYPE_SMS_REQUEST_CONVERSATIONS] with the first message in all conversations that will be send
*/
@WorkerThread
private fun handleRequestAllConversations(packet: NetworkPacket): Boolean {
haveMessagesBeenRequested = true
val conversations: Iterator<SMSHelper.Message> = getConversations(this.context).iterator()
while (conversations.hasNext()) {
val message: SMSHelper.Message = conversations.next()
val partialReply: NetworkPacket = constructBulkMessagePacket(setOf(message))
device.sendPacket(partialReply)
}
return true
}
@WorkerThread
private fun handleRequestSingleConversation(packet: NetworkPacket): Boolean {
haveMessagesBeenRequested = true
val threadID = ThreadID(packet.getLong("threadID"))
val rangeStartTimestamp: Long = packet.getLong("rangeStartTimestamp", -1)
var numberToGet: Long? = packet.getLong("numberToRequest", -1)
if (numberToGet!! < 0) {
numberToGet = null
}
val conversation: List<SMSHelper.Message>
if (rangeStartTimestamp < 0) {
conversation = getMessagesInThread(this.context, threadID, numberToGet)
} else {
conversation = getMessagesInRange(this.context, threadID, rangeStartTimestamp, numberToGet, true)
}
val reply: NetworkPacket = constructBulkMessagePacket(conversation)
device.sendPacket(reply)
return true
}
private fun isNumberBlocked(number: String?): Boolean {
val sharedPref: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
val blockedNumbers: Array<String> =
sharedPref.getString(KEY_PREF_BLOCKED_NUMBERS, "")!!.split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
for (s: String in blockedNumbers) {
if (PhoneNumberUtils.compare(number, s)) return true
}
return false
}
override fun hasSettings(): Boolean = true
override fun getSettingsFragment(activity: Activity): PluginSettingsFragment? = PluginSettingsFragment.newInstance(pluginKey, R.xml.smsplugin_preferences)
override val supportedPacketTypes: Array<String>
get() = arrayOf(
PACKET_TYPE_SMS_REQUEST,
TelephonyPlugin.PACKET_TYPE_TELEPHONY_REQUEST,
PACKET_TYPE_SMS_REQUEST_CONVERSATIONS,
PACKET_TYPE_SMS_REQUEST_CONVERSATION,
PACKET_TYPE_SMS_REQUEST_ATTACHMENT
)
override val outgoingPacketTypes: Array<String>
get() = arrayOf(PACKET_TYPE_SMS_MESSAGE, PACKET_TYPE_SMS_ATTACHMENT_FILE)
override val requiredPermissions: Array<String>
get() = arrayOf(
Manifest.permission.SEND_SMS,
Manifest.permission.READ_SMS, // READ_PHONE_STATE should be optional, since we can just query the user, but that
// requires a GUI implementation for querying the user!
Manifest.permission.READ_PHONE_STATE,
)
/**
* With versions older than KITKAT, lots of the content providers used in SMSHelper become
* un-documented. Most manufacturers *did* do things the same way as was done in mainline
* Android at that time, but some did not. If the manufacturer followed the default route,
* everything will be fine. If not, the plugin will crash. But, since we have a global catch-all
* in [org.kde.kdeconnect.Device.onPacketReceived], it will not crash catastrophically.
* The [SMSPlugin.onCreate] method of this SMSPlugin complains if a version older than KitKat is loaded,
* but it still allowed in the optimistic hope that things will "just work"
*/
override val minSdk: Int = Build.VERSION_CODES.FROYO
companion object {
/**
* Packet used to indicate a batch of messages has been pushed from the remote device
*
* The body should contain the key "messages" mapping to an array of messages
*
* For example:
* ```
* {
* "version": 2 // This is the second version of this packet type and version 1 packets
* // (which did not carry this flag) are incompatible with the new format
* "messages" : [
* {
* "event" : 1, // 32-bit field containing a bitwise-or of event flags
* // See constants declared in SMSHelper.Message for define
* // values and explanations
* "body" : "Hello", // Text message body
* "addresses": <List<Address>> // List of Address objects, one for each participant of the conversation
* // The user's Address is excluded so:
* // If this is a single-target messsage, there will only be one
* // Address (the other party)
* // If this is an incoming multi-target message, the first Address is the
* // sender and all other addresses are other parties to the conversation
* // If this is an outgoing multi-target message, the sender is implicit
* // (the user's phone number) and all Addresses are recipients
* "date" : "1518846484880", // Timestamp of the message
* "type" : "2", // Compare with Android's Telephony.TextBasedSmsColumns.MESSAGE_TYPE_*
* "thread_id" : 132 // Thread to which the message belongs
* "read" : true // Boolean representing whether a message is read or unread
* },
* ...
* ]
* }
* ```
*
* The following optional fields of a message object may be defined
* "sub_id": <int> // Android's subscriber ID, which is basically used to determine which SIM card the message
* // belongs to. This is mostly useful when attempting to reply to an SMS with the correct
* // SIM card using [PACKET_TYPE_SMS_REQUEST].
* // If this value is not defined or if it does not match a valid subscriber_id known by
* // Android, we will use whatever subscriber ID Android gives us as the default
*
* "attachments": <List<Attachment>> // List of Attachment objects, one for each attached file in the message.
*
* An Attachment object looks like:
* {
* "part_id": <long> // part_id of the attachment used to read the file from MMS database
* "mime_type": <String> // contains the mime type of the file (eg: image/jpg, video/mp4 etc.)
* "encoded_thumbnail": <String> // Optional base64-encoded thumbnail preview of the content for types which support it
* "unique_identifier": <String> // Unique name of te file
* }
*
* An Address object looks like:
* {
* "address": <String> // Address (phone number, email address, etc.) of this object
* }
*/
private const val PACKET_TYPE_SMS_MESSAGE: String = "kdeconnect.sms.messages"
private const val SMS_MESSAGE_PACKET_VERSION: Int = 2 // We *send* packets of this version
/**
* Packet sent to request a message be sent
*
* The body should look like so:
* {
* "version": 2, // The version of the packet being sent. Compare to SMS_REQUEST_PACKET_VERSION before attempting to handle.
* "sendSms": true, // (Depreciated, ignored) Old versions of the desktop app used to mix phone calls, SMS, etc. in the same packet type and used this field to differentiate.
* "phoneNumber": "542904563213", // (Depreciated) Retained for backwards-compatibility. Old versions of the desktop app send a single phoneNumber. Use the Addresses field instead.
* "addresses": <List of Addresses> // The one or many targets of this message
* "messageBody": "Hi mom!", // Plain-text string to be sent as the body of the message (Optional if sending an attachment)
* "attachments": <List of Attached files>,
* "sub_id": 3859358340534 // Some magic number which tells Android which SIM card to use (Optional, if omitted, sends with the default SIM card)
* }
*
* An AttachmentContainer object looks like:
* {
* "fileName": <String> // Name of the file
* "base64EncodedFile": <String> // Base64 encoded file
* "mimeType": <String> // File type (eg: image/jpg, video/mp4 etc.)
* }
*/
private const val PACKET_TYPE_SMS_REQUEST: String = "kdeconnect.sms.request"
/** We *handle* packets of this version or lower. Update this number only if future packets break backwards-compatibility. **/
private const val SMS_REQUEST_PACKET_VERSION: Int = 2
/**
* Packet sent to request the most-recent message in each conversations on the device
*
* The request packet shall contain no body
*/
private const val PACKET_TYPE_SMS_REQUEST_CONVERSATIONS: String = "kdeconnect.sms.request_conversations"
/**
* Packet sent to request all the messages in a particular conversation
*
* The following fields are available:
* "threadID": <long> // (Required) ThreadID to request
* "rangeStartTimestamp": <long> // (Optional) Millisecond epoch timestamp indicating the start of the range from which to return messages
* "numberToRequest": <long> // (Optional) Number of messages to return, starting from rangeStartTimestamp.
* // May return fewer than expected if there are not enough or more than expected if many
* // messages have the same timestamp.
*/
private const val PACKET_TYPE_SMS_REQUEST_CONVERSATION: String = "kdeconnect.sms.request_conversation"
/**
* Packet sent to request an attachment file in a particular message of a conversation
*
*
* The body should look like so:
* "part_id": <long> // Part id of the attachment
* "unique_identifier": <String> // This unique_identifier should come from a previous message packet's attachment field
*/
private const val PACKET_TYPE_SMS_REQUEST_ATTACHMENT: String = "kdeconnect.sms.request_attachment"
/**
* Packet used to send original attachment file from mms database to desktop
*
*
* The following fields are available:
* "filename": <String> // Name of the attachment file in the database
* "payload": // Actual attachment file to be transferred
*/
private const val PACKET_TYPE_SMS_ATTACHMENT_FILE: String = "kdeconnect.sms.attachment_file"
private const val KEY_PREF_BLOCKED_NUMBERS: String = "telephony_blocked_numbers"
/**
* Construct a proper packet of [PACKET_TYPE_SMS_MESSAGE] from the passed messages
*
* @param messages Messages to include in the packet
* @return NetworkPacket of type [PACKET_TYPE_SMS_MESSAGE]
*/
private fun constructBulkMessagePacket(messages: Iterable<SMSHelper.Message>): NetworkPacket {
val reply = NetworkPacket(PACKET_TYPE_SMS_MESSAGE)
val body = JSONArray()
for (message: SMSHelper.Message in messages) {
try {
val json: JSONObject = message.toJSONObject()
body.put(json)
} catch (e: JSONException) {
Log.e("Conversations", "Error serializing message", e)
}
}
reply["messages"] = body
reply["version"] = SMS_MESSAGE_PACKET_VERSION
return reply
}
/**
* Permissions required for sending and receiving MMs messages
*/
val mmsPermissions: Array<String>
get() = arrayOf(
Manifest.permission.RECEIVE_SMS,
Manifest.permission.RECEIVE_MMS,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.CHANGE_NETWORK_STATE,
Manifest.permission.WAKE_LOCK,
)
}
}

View File

@@ -1,528 +0,0 @@
/*
* SPDX-FileCopyrightText: 2020 Aniket Kumar <anikketkumar786@gmail.com>
* SPDX-FileCopyrightText: 2021 Simon Redman <simon@ergotech.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SMSPlugin;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.provider.Telephony;
import android.telephony.SmsManager;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import com.android.mms.dom.smil.parser.SmilXmlSerializer;
import com.google.android.mms.ContentType;
import com.google.android.mms.InvalidHeaderValueException;
import com.google.android.mms.MMSPart;
import com.google.android.mms.pdu_alt.CharacterSets;
import com.google.android.mms.pdu_alt.EncodedStringValue;
import com.google.android.mms.pdu_alt.MultimediaMessagePdu;
import com.google.android.mms.pdu_alt.PduBody;
import com.google.android.mms.pdu_alt.PduComposer;
import com.google.android.mms.pdu_alt.PduHeaders;
import com.google.android.mms.pdu_alt.PduPart;
import com.google.android.mms.pdu_alt.RetrieveConf;
import com.google.android.mms.pdu_alt.SendReq;
import com.google.android.mms.smil.SmilHelper;
import com.klinker.android.send_message.Message;
import com.klinker.android.send_message.Settings;
import com.klinker.android.send_message.Transaction;
import com.klinker.android.send_message.Utils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.kde.kdeconnect.Helpers.SMSHelper;
import org.kde.kdeconnect.Helpers.TelephonyHelper;
import org.kde.kdeconnect.NetworkPacket;
import org.kde.kdeconnect_tp.R;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Random;
public class SmsMmsUtils {
private static final String SENDING_MESSAGE = "Sending message";
/**
* Sends SMS or MMS message.
*
* @param context context in which the method is called.
* @param textMessage text body of the message to be sent.
* @param addressList List of addresses.
* @param attachedFiles List of attachments. Pass empty list if none.
* @param subID Note that here subID is of type int and not long because klinker library requires it as int
* I don't really know the exact reason why they implemented it as int instead of long
*/
public static void sendMessage(
Context context,
String textMessage,
@NonNull List<SMSHelper.Attachment> attachedFiles,
List<SMSHelper.Address> addressList,
int subID
) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
boolean longTextAsMms = prefs.getBoolean(context.getString(R.string.set_long_text_as_mms), false);
boolean groupMessageAsMms = prefs.getBoolean(context.getString(R.string.set_group_message_as_mms), true);
int sendLongAsMmsAfter = Integer.parseInt(
prefs.getString(context.getString(R.string.convert_to_mms_after),
context.getString(R.string.convert_to_mms_after_default)));
TelephonyHelper.LocalPhoneNumber sendingPhoneNumber;
List<TelephonyHelper.LocalPhoneNumber> allPhoneNumbers = TelephonyHelper.getAllPhoneNumbers(context);
Optional<TelephonyHelper.LocalPhoneNumber> maybeSendingPhoneNumber = allPhoneNumbers.stream()
.filter(localPhoneNumber -> localPhoneNumber.subscriptionID == subID)
.findAny();
if (maybeSendingPhoneNumber.isPresent()) {
sendingPhoneNumber = maybeSendingPhoneNumber.get();
} else {
if (allPhoneNumbers.isEmpty()) {
// We were not able to get any phone number for the user's device
// Use a null "dummy" number instead. This should behave the same as not setting
// the FromAddress (below) since the default value there is null.
// The only more-correct thing we could do here is query the user (maybe in a
// persistent configuration) for their phone number(s).
sendingPhoneNumber = new TelephonyHelper.LocalPhoneNumber(null, subID);
Log.w(SENDING_MESSAGE, "We do not know *any* phone numbers for this device. "
+ "Attempting to send a message without knowing the local phone number is likely "
+ "to result in strange behavior, such as the message being sent to yourself, "
+ "or might entirely fail to send (or be received).");
} else {
// Pick an arbitrary phone number
sendingPhoneNumber = allPhoneNumbers.get(0);
}
Log.w(SENDING_MESSAGE, "Unable to determine correct outgoing address for sub ID " + subID + ". Using " + sendingPhoneNumber);
}
if (sendingPhoneNumber.number != null) {
// If the message is going to more than one target (to allow the user to send a message to themselves)
if (addressList.size() > 1) {
// Remove the user's phone number if present in the list of recipients
addressList.removeIf(address -> sendingPhoneNumber.isMatchingPhoneNumber(address.getAddress()));
}
}
try {
Settings settings = new Settings();
TelephonyHelper.ApnSetting apnSettings = TelephonyHelper.getPreferredApn(context, subID);
if (apnSettings != null) {
settings.setMmsc(apnSettings.getMmsc().toString());
settings.setProxy(apnSettings.getMmsProxyAddressAsString());
settings.setPort(Integer.toString(apnSettings.getMmsProxyPort()));
} else {
settings.setUseSystemSending(true);
}
settings.setSendLongAsMms(longTextAsMms);
settings.setSendLongAsMmsAfter(sendLongAsMmsAfter);
settings.setGroup(groupMessageAsMms);
if (subID != -1) {
settings.setSubscriptionId(subID);
}
Transaction transaction = new Transaction(context, settings);
List<String> addresses = new ArrayList<>();
for (SMSHelper.Address address : addressList) {
addresses.add(address.toString());
}
Message message = new Message(textMessage, addresses.toArray(ArrayUtils.EMPTY_STRING_ARRAY));
// If there are any attachment files add those into the message
for (SMSHelper.Attachment attachedFile : attachedFiles) {
byte[] file = Base64.decode(attachedFile.base64EncodedFile, Base64.DEFAULT);
String mimeType = attachedFile.mimeType;
String fileName = attachedFile.uniqueIdentifier;
message.addMedia(file, mimeType, fileName);
}
message.setFromAddress(sendingPhoneNumber.number);
message.setSave(true);
// Sending MMS on android requires the app to be set as the default SMS app,
// but sending SMS doesn't needs the app to be set as the default app.
// This is the reason why there are separate branch handling for SMS and MMS.
if (transaction.checkMMS(message)) {
Log.v("", "Sending new MMS");
//transaction.sendNewMessage(message, Transaction.NO_THREAD_ID);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
sendMmsMessageNative(context, message, settings);
} else {
// Cross fingers and hope Klinker's library works for this case
transaction.sendNewMessage(message, Transaction.NO_THREAD_ID);
}
} else {
Log.v(SENDING_MESSAGE, "Sending new SMS");
transaction.sendNewMessage(message, Transaction.NO_THREAD_ID);
}
//TODO: Notify other end
} catch (Exception e) {
//TODO: Notify other end
Log.e(SENDING_MESSAGE, "Exception", e);
}
}
/**
* Send an MMS message using SmsManager.sendMultimediaMessage
*
* @param context
* @param message
* @param klinkerSettings
*/
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP_MR1)
protected static void sendMmsMessageNative(Context context, Message message, Settings klinkerSettings) {
ArrayList<MMSPart> data = new ArrayList<>();
for (Message.Part p : message.getParts()) {
MMSPart part = new MMSPart();
if (p.getName() != null) {
part.Name = p.getName();
} else {
part.Name = p.getContentType().split("/")[0];
}
part.MimeType = p.getContentType();
part.Data = p.getMedia();
data.add(part);
}
if (message.getText() != null && !message.getText().equals("")) {
// add text to the end of the part and send
MMSPart part = new MMSPart();
part.Name = "text";
part.MimeType = "text/plain";
part.Data = message.getText().getBytes();
data.add(part);
}
SendReq sendReq = buildPdu(context, message.getFromAddress(), message.getAddresses(), message.getSubject(), data, klinkerSettings);
Bundle configOverrides = new Bundle();
configOverrides.putBoolean(SmsManager.MMS_CONFIG_GROUP_MMS_ENABLED, klinkerSettings.getGroup());
// Write the PDUs to disk so that we can pass them to the SmsManager
final String fileName = "send." + Math.abs(new Random().nextLong()) + ".dat";
File mSendFile = new File(context.getCacheDir(), fileName);
Uri contentUri = (new Uri.Builder())
.authority(context.getPackageName() + ".MmsFileProvider")
.path(fileName)
.scheme(ContentResolver.SCHEME_CONTENT)
.build();
try (FileOutputStream writer = new FileOutputStream(mSendFile)) {
writer.write(new PduComposer(context, sendReq).make());
} catch (final IOException e)
{
android.util.Log.e(SENDING_MESSAGE, "Error while writing temporary PDU file: ", e);
}
SmsManager mSmsManager;
if (klinkerSettings.getSubscriptionId() < 0)
{
mSmsManager = SmsManager.getDefault();
} else {
mSmsManager = SmsManager.getSmsManagerForSubscriptionId(klinkerSettings.getSubscriptionId());
}
mSmsManager.sendMultimediaMessage(context, contentUri, null, null, null);
}
public static final long DEFAULT_EXPIRY_TIME = 7 * 24 * 60 * 60;
public static final int DEFAULT_PRIORITY = PduHeaders.PRIORITY_NORMAL;
/**
* Copy of the same-name method from https://github.com/klinker41/android-smsmms
*/
private static SendReq buildPdu(Context context, String fromAddress, String[] recipients, String subject,
List<MMSPart> parts, Settings settings) {
final SendReq req = new SendReq();
// From, per spec
req.prepareFromAddress(context, fromAddress, settings.getSubscriptionId());
// To
for (String recipient : recipients) {
req.addTo(new EncodedStringValue(recipient));
}
// Subject
if (!TextUtils.isEmpty(subject)) {
req.setSubject(new EncodedStringValue(subject));
}
// Date
req.setDate(System.currentTimeMillis() / 1000);
// Body
PduBody body = new PduBody();
// Add text part. Always add a smil part for compatibility, without it there
// may be issues on some carriers/client apps
int size = 0;
for (int i = 0; i < parts.size(); i++) {
MMSPart part = parts.get(i);
size += addTextPart(body, part, i);
}
// add a SMIL document for compatibility
ByteArrayOutputStream out = new ByteArrayOutputStream();
SmilXmlSerializer.serialize(SmilHelper.createSmilDocument(body), out);
PduPart smilPart = new PduPart();
smilPart.setContentId("smil".getBytes());
smilPart.setContentLocation("smil.xml".getBytes());
smilPart.setContentType(ContentType.APP_SMIL.getBytes());
smilPart.setData(out.toByteArray());
body.addPart(0, smilPart);
req.setBody(body);
// Message size
req.setMessageSize(size);
// Message class
req.setMessageClass(PduHeaders.MESSAGE_CLASS_PERSONAL_STR.getBytes());
// Expiry
req.setExpiry(DEFAULT_EXPIRY_TIME);
try {
// Priority
req.setPriority(DEFAULT_PRIORITY);
// Delivery report
req.setDeliveryReport(PduHeaders.VALUE_NO);
// Read report
req.setReadReport(PduHeaders.VALUE_NO);
} catch (InvalidHeaderValueException e) {}
return req;
}
/**
* Copy of the same-name method from https://github.com/klinker41/android-smsmms
*/
private static int addTextPart(PduBody pb, MMSPart p, int id) {
String filename = p.Name;
final PduPart part = new PduPart();
// Set Charset if it's a text media.
if (p.MimeType.startsWith("text")) {
part.setCharset(CharacterSets.UTF_8);
}
// Set Content-Type.
part.setContentType(p.MimeType.getBytes());
// Set Content-Location.
part.setContentLocation(filename.getBytes());
int index = filename.lastIndexOf(".");
String contentId = (index == -1) ? filename
: filename.substring(0, index);
part.setContentId(contentId.getBytes());
part.setData(p.Data);
pb.addPart(part);
return part.getData().length;
}
/**
* Returns the Address of the sender of the MMS message.
* @return sender's Address
*/
public static SMSHelper.Address getMmsFrom(Context context, MultimediaMessagePdu msg) {
if (msg == null) { return null; }
EncodedStringValue encodedStringValue = msg.getFrom();
return new SMSHelper.Address(context, encodedStringValue.getString());
}
/**
* returns a List of Addresses of all the recipients of a MMS message.
* @return List of Addresses of all recipients of an MMS message
*/
public static List<SMSHelper.Address> getMmsTo(Context context, MultimediaMessagePdu msg) {
if (msg == null) { return null; }
StringBuilder toBuilder = new StringBuilder();
EncodedStringValue[] to = msg.getTo();
if (to != null) {
toBuilder.append(EncodedStringValue.concat(to));
}
if (msg instanceof RetrieveConf) {
EncodedStringValue[] cc = ((RetrieveConf) msg).getCc();
if (cc != null && cc.length != 0) {
toBuilder.append(";");
toBuilder.append(EncodedStringValue.concat(cc));
}
}
String built = toBuilder.toString().replace(";", ", ");
if (built.startsWith(", ")) {
built = built.substring(2);
}
return stripDuplicatePhoneNumbers(context, built);
}
/**
* Removes duplicate addresses from the string and returns List of Addresses
*/
public static List<SMSHelper.Address> stripDuplicatePhoneNumbers(Context context, String phoneNumbers) {
if (phoneNumbers == null) {
return null;
}
String[] numbers = phoneNumbers.split(", ");
List<SMSHelper.Address> uniqueNumbers = new ArrayList<>();
for (String number : numbers) {
// noinspection SuspiciousMethodCalls
if (!uniqueNumbers.contains(number.trim())) {
uniqueNumbers.add(new SMSHelper.Address(context, number.trim()));
}
}
return uniqueNumbers;
}
/**
* Converts a given bitmap to an encoded Base64 string for sending to desktop
* @param bitmap bitmap to be encoded into string*
* @return Returns the Base64 encoded string
*/
public static String bitMapToBase64(Bitmap bitmap) {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
// The below line is not really compressing to PNG so much as encoding as PNG, since PNG is lossless
boolean isCompressed = bitmap.compress(Bitmap.CompressFormat.PNG,100, byteArrayOutputStream);
if (isCompressed) {
byte[] b = byteArrayOutputStream.toByteArray();
String encodedString = Base64.encodeToString(b, Base64.DEFAULT);
return encodedString;
}
return null;
}
/**
* Reads the image files attached with an MMS from MMS database
* @param context Context in which the method is called
* @param id part ID of the image file attached with an MMS message
* @return Returns the image as a bitmap
*/
public static Bitmap getMmsImage(Context context, long id) {
Uri partURI = ContentUris.withAppendedId(SMSHelper.mMSPartUri, id);
Bitmap bitmap = null;
try (InputStream inputStream = context.getContentResolver().openInputStream(partURI)) {
bitmap = BitmapFactory.decodeStream(inputStream);
} catch (IOException e) {
Log.e("SmsMmsUtils", "Exception", e);
}
return bitmap;
}
/**
* This method loads the byteArray of attachment file stored in the MMS database
* @param context Context in which the method is called
* @param id part ID of the particular multimedia attachment file of MMS
* @return returns the byteArray of the attachment
*/
public static byte[] loadAttachment(Context context, long id) {
Uri partURI = ContentUris.withAppendedId(SMSHelper.mMSPartUri, id);
byte[] byteArray = new byte[0];
// Open inputStream from the specified URI
try (InputStream inputStream = context.getContentResolver().openInputStream(partURI)) {
// Try read from the InputStream
if (inputStream != null) {
byteArray = IOUtils.toByteArray(inputStream);
}
} catch (IOException e) {
e.printStackTrace();
}
return byteArray;
}
/**
* Create a SMS attachment packet using the partID of the file requested by the device
*/
public static NetworkPacket partIdToMessageAttachmentPacket(
final Context context,
final long partID,
final String filename,
String type
) {
byte[] attachment = loadAttachment(context, partID);
long size = attachment.length;
if (size == 0) {
Log.e("SmsMmsUtils", "Loaded attachment is empty.");
}
try {
InputStream inputStream = new ByteArrayInputStream(attachment);
NetworkPacket np = new NetworkPacket(type);
np.set("filename", filename);
np.setPayload(new NetworkPacket.Payload(inputStream, size));
return np;
} catch (Exception e) {
return null;
}
}
/**
* Marks a conversation as read in the database.
*
* @param context the context to get the content provider with.
* @param recipients the phone numbers to find the conversation with.
*/
public static void markConversationRead(Context context, HashSet<String> recipients) {
new Thread() {
@Override
public void run() {
try {
long threadId = Utils.getOrCreateThreadId(context, recipients);
markAsRead(context, ContentUris.withAppendedId(Telephony.Threads.CONTENT_URI, threadId), threadId);
} catch (Exception e) {
// the conversation doesn't exist
e.printStackTrace();
}
}
}.start();
}
private static void markAsRead(Context context, Uri uri, long threadId) {
Log.v("SMSPlugin", "marking thread with threadId " + threadId + " as read at Uri" + uri);
if (uri != null && context != null) {
ContentValues values = new ContentValues(2);
values.put("read", 1);
values.put("seen", 1);
context.getContentResolver().update(uri, values, "(read=0 OR seen=0)", null);
}
}
}

View File

@@ -0,0 +1,504 @@
/*
* SPDX-FileCopyrightText: 2020 Aniket Kumar <anikketkumar786@gmail.com>
* SPDX-FileCopyrightText: 2021 Simon Redman <simon@ergotech.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.Plugins.SMSPlugin
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.SharedPreferences
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.preference.PreferenceManager
import android.provider.Telephony
import android.telephony.SmsManager
import android.text.TextUtils
import android.util.Base64
import android.util.Log
import androidx.annotation.RequiresApi
import com.android.mms.dom.smil.parser.SmilXmlSerializer
import com.google.android.mms.ContentType
import com.google.android.mms.InvalidHeaderValueException
import com.google.android.mms.MMSPart
import com.google.android.mms.pdu_alt.CharacterSets
import com.google.android.mms.pdu_alt.EncodedStringValue
import com.google.android.mms.pdu_alt.MultimediaMessagePdu
import com.google.android.mms.pdu_alt.PduBody
import com.google.android.mms.pdu_alt.PduComposer
import com.google.android.mms.pdu_alt.PduHeaders
import com.google.android.mms.pdu_alt.PduPart
import com.google.android.mms.pdu_alt.RetrieveConf
import com.google.android.mms.pdu_alt.SendReq
import com.google.android.mms.smil.SmilHelper
import com.klinker.android.send_message.Message
import com.klinker.android.send_message.Settings
import com.klinker.android.send_message.Transaction
import com.klinker.android.send_message.Utils
import org.apache.commons.io.IOUtils
import org.kde.kdeconnect.Helpers.SMSHelper
import org.kde.kdeconnect.Helpers.TelephonyHelper
import org.kde.kdeconnect.Helpers.TelephonyHelper.LocalPhoneNumber
import org.kde.kdeconnect.NetworkPacket
import org.kde.kdeconnect_tp.R
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.Random
import kotlin.concurrent.thread
import kotlin.math.abs
object SmsMmsUtils {
private const val SENDING_MESSAGE = "Sending message"
private fun getSendingPhoneNumber(context: Context, subscriptionID: Int): LocalPhoneNumber {
val sendingPhoneNumber: LocalPhoneNumber
val allPhoneNumbers = TelephonyHelper.getAllPhoneNumbers(context)
val maybeSendingPhoneNumber = allPhoneNumbers.firstOrNull { localPhoneNumber -> localPhoneNumber.subscriptionID == subscriptionID }
if (maybeSendingPhoneNumber != null) {
sendingPhoneNumber = maybeSendingPhoneNumber
}
else {
if (allPhoneNumbers.isEmpty()) {
// We were not able to get any phone number for the user's device
// Use a null "dummy" number instead. This should behave the same as not setting
// the FromAddress (below) since the default value there is null.
// The only more-correct thing we could do here is query the user (maybe in a
// persistent configuration) for their phone number(s).
sendingPhoneNumber = LocalPhoneNumber(null, subscriptionID)
Log.w(SENDING_MESSAGE, ("We do not know *any* phone numbers for this device. "
+ "Attempting to send a message without knowing the local phone number is likely "
+ "to result in strange behavior, such as the message being sent to yourself, "
+ "or might entirely fail to send (or be received).")
)
} else {
// Pick an arbitrary phone number
sendingPhoneNumber = allPhoneNumbers[0]
}
Log.w(SENDING_MESSAGE, "Unable to determine correct outgoing address for sub ID $subscriptionID. Using $sendingPhoneNumber")
}
return sendingPhoneNumber
}
private fun getTransactionSettings(context: Context, subID: Int, prefs: SharedPreferences): Settings {
val longTextAsMms = prefs.getBoolean(context.getString(R.string.set_long_text_as_mms), false)
val groupMessageAsMms = prefs.getBoolean(context.getString(R.string.set_group_message_as_mms), true)
val sendLongAsMmsAfter = prefs.getString(context.getString(R.string.convert_to_mms_after), context.getString(R.string.convert_to_mms_after_default))!!.toInt()
val settings = Settings()
val apnSettings = TelephonyHelper.getPreferredApn(context, subID)
if (apnSettings != null) {
settings.mmsc = apnSettings.mmsc.toString()
settings.proxy = apnSettings.mmsProxyAddressAsString
settings.port = apnSettings.mmsProxyPort.toString()
} else {
settings.useSystemSending = true
}
settings.sendLongAsMms = longTextAsMms
settings.sendLongAsMmsAfter = sendLongAsMmsAfter
settings.group = groupMessageAsMms
if (subID != -1) {
settings.subscriptionId = subID
}
return settings
}
/**
* Sends SMS or MMS message.
*
* @param context context in which the method is called.
* @param textMessage text body of the message to be sent.
* @param addressList List of addresses.
* @param attachedFiles List of attachments. Pass empty list if none.
* @param subID Note that here subID is of type int and not long because klinker library requires it as int
* I don't really know the exact reason why they implemented it as int instead of long
*/
fun sendMessage(context: Context, textMessage: String?, attachedFiles: List<SMSHelper.Attachment>, addressList: MutableList<SMSHelper.Address>, subID: Int) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val sendingPhoneNumber: LocalPhoneNumber = getSendingPhoneNumber(context, subID)
if (sendingPhoneNumber.number != null) {
// If the message is going to more than one target (to allow the user to send a message to themselves)
if (addressList.size > 1) {
// Remove the user's phone number if present in the list of recipients
addressList.removeIf { address -> sendingPhoneNumber.isMatchingPhoneNumber(address.getAddress()) }
}
}
try {
val settings = getTransactionSettings(context, subID, prefs)
val transaction = Transaction(context, settings)
val addresses: Array<String> = addressList.map(SMSHelper.Address::getAddress).toTypedArray()
val message = Message(textMessage, addresses)
// If there are any attachment files add those into the message
for (attachedFile in attachedFiles) {
val file = Base64.decode(attachedFile.base64EncodedFile, Base64.DEFAULT)
val mimeType = attachedFile.mimeType
val fileName = attachedFile.uniqueIdentifier
message.addMedia(file, mimeType, fileName)
}
message.fromAddress = sendingPhoneNumber.number
message.save = true
// Sending MMS on android requires the app to be set as the default SMS app,
// but sending SMS doesn't needs the app to be set as the default app.
// This is the reason why there are separate branch handling for SMS and MMS.
if (transaction.checkMMS(message)) {
Log.v("", "Sending new MMS")
//transaction.sendNewMessage(message, Transaction.NO_THREAD_ID);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
sendMmsMessageNative(context, message, settings)
} else {
// Cross fingers and hope Klinker's library works for this case
transaction.sendNewMessage(message, Transaction.NO_THREAD_ID)
}
} else {
Log.v(SENDING_MESSAGE, "Sending new SMS")
transaction.sendNewMessage(message, Transaction.NO_THREAD_ID)
}
// TODO: Notify other end
} catch (e: Exception) {
// TODO: Notify other end
Log.e(SENDING_MESSAGE, "Exception", e)
}
}
/**
* Send an MMS message using SmsManager.sendMultimediaMessage
*
* @param context
* @param message
* @param klinkerSettings
*/
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP_MR1)
internal fun sendMmsMessageNative(context: Context, message: Message, klinkerSettings: Settings) {
val data = ArrayList<MMSPart>()
for (p in message.parts) {
val part = MMSPart()
if (p.name != null) {
part.Name = p.name
} else {
part.Name = p.contentType.split("/").dropLastWhile { it.isEmpty() }.toTypedArray()[0]
}
part.MimeType = p.contentType
part.Data = p.media
data.add(part)
}
if (message.text != null && message.text != "") {
// add text to the end of the part and send
val part = MMSPart()
part.Name = "text"
part.MimeType = "text/plain"
part.Data = message.text.toByteArray()
data.add(part)
}
val sendReq = buildPdu(context, message.fromAddress, message.addresses, message.subject, data, klinkerSettings)
val configOverrides = Bundle()
configOverrides.putBoolean(SmsManager.MMS_CONFIG_GROUP_MMS_ENABLED, klinkerSettings.group)
// Write the PDUs to disk so that we can pass them to the SmsManager
val fileName = "send.${abs(Random().nextLong().toDouble())}.dat"
val mSendFile = File(context.cacheDir, fileName)
val contentUri = (Uri.Builder())
.authority("${context.packageName}.MmsFileProvider")
.path(fileName)
.scheme(ContentResolver.SCHEME_CONTENT)
.build()
try {
FileOutputStream(mSendFile).use { writer ->
writer.write(PduComposer(context, sendReq).make())
}
} catch (e: IOException) {
Log.e(SENDING_MESSAGE, "Error while writing temporary PDU file: ", e)
}
val mSmsManager = if (klinkerSettings.subscriptionId < 0) {
SmsManager.getDefault()
} else {
SmsManager.getSmsManagerForSubscriptionId(klinkerSettings.subscriptionId)
}
mSmsManager.sendMultimediaMessage(context, contentUri, null, null, null)
}
const val DEFAULT_EXPIRY_TIME: Long = (7 * 24 * 60 * 60).toLong()
const val DEFAULT_PRIORITY: Int = PduHeaders.PRIORITY_NORMAL
/**
* Copy of the same-name method from https://github.com/klinker41/android-smsmms
*/
private fun buildPdu(context: Context, fromAddress: String, recipients: Array<String>, subject: String, parts: List<MMSPart>, settings: Settings): SendReq {
val req = SendReq()
// From, per spec
req.prepareFromAddress(context, fromAddress, settings.subscriptionId)
// To
for (recipient in recipients) {
req.addTo(EncodedStringValue(recipient))
}
// Subject
if (!TextUtils.isEmpty(subject)) {
req.subject = EncodedStringValue(subject)
}
// Date
req.date = System.currentTimeMillis() / 1000
// Body
val body = PduBody()
// Add text part. Always add a smil part for compatibility, without it there
// may be issues on some carriers/client apps
var size = 0
for (i in parts.indices) {
val part = parts[i]
size += addTextPart(body, part, i)
}
// add a SMIL document for compatibility
val out = ByteArrayOutputStream()
SmilXmlSerializer.serialize(SmilHelper.createSmilDocument(body), out)
val smilPart = PduPart()
smilPart.contentId = "smil".toByteArray()
smilPart.contentLocation = "smil.xml".toByteArray()
smilPart.contentType = ContentType.APP_SMIL.toByteArray()
smilPart.data = out.toByteArray()
body.addPart(0, smilPart)
req.body = body
// Message size
req.messageSize = size.toLong()
// Message class
req.messageClass = PduHeaders.MESSAGE_CLASS_PERSONAL_STR.toByteArray()
// Expiry
req.expiry = DEFAULT_EXPIRY_TIME
try {
// Priority
req.priority = DEFAULT_PRIORITY
// Delivery report
req.deliveryReport = PduHeaders.VALUE_NO
// Read report
req.readReport = PduHeaders.VALUE_NO
} catch (_: InvalidHeaderValueException) { }
return req
}
/**
* Copy of the same-name method from https://github.com/klinker41/android-smsmms
*/
private fun addTextPart(pb: PduBody, p: MMSPart, id: Int): Int {
val filename = p.Name
val part = PduPart()
// Set Charset if it's a text media.
if (p.MimeType.startsWith("text")) {
part.charset = CharacterSets.UTF_8
}
// Set Content-Type.
part.contentType = p.MimeType.toByteArray()
// Set Content-Location.
part.contentLocation = filename.toByteArray()
val index = filename.lastIndexOf(".")
val contentId = if (index == -1) filename else filename.substring(0, index)
part.contentId = contentId.toByteArray()
part.data = p.Data
pb.addPart(part)
return part.data.size
}
/**
* Returns the Address of the sender of the MMS message.
* @return sender's Address
*/
fun getMmsFrom(context: Context, msg: MultimediaMessagePdu?): SMSHelper.Address? {
if (msg == null) {
return null
}
return SMSHelper.Address(context, msg.from.string)
}
/**
* returns a List of Addresses of all the recipients of a MMS message.
* @return List of Addresses of all recipients of an MMS message
*/
fun getMmsTo(context: Context, msg: MultimediaMessagePdu?): List<SMSHelper.Address>? {
if (msg == null) {
return null
}
val toBuilder = StringBuilder()
val to = msg.to
if (to != null) {
toBuilder.append(EncodedStringValue.concat(to))
}
if (msg is RetrieveConf) {
val cc = msg.cc
if (cc != null && cc.isNotEmpty()) {
toBuilder.append(";")
toBuilder.append(EncodedStringValue.concat(cc))
}
}
val built = toBuilder.toString().replace(";", ", ").removePrefix(", ")
return stripDuplicatePhoneNumbers(context, built)
}
/**
* Removes duplicate addresses from the string and returns List of Addresses
*/
fun stripDuplicatePhoneNumbers(context: Context, phoneNumbers: String): List<SMSHelper.Address> {
val numbers = phoneNumbers.split(", ").dropLastWhile { it.isEmpty() }.toTypedArray()
val uniqueNumbers = mutableListOf<SMSHelper.Address>()
for (number in numbers) {
val duplicate = uniqueNumbers.any { uniqueNumber -> uniqueNumber.getAddress() == number.trim { it <= ' ' } }
if (!duplicate) {
uniqueNumbers.add(SMSHelper.Address(context, number.trim { it <= ' ' }))
}
}
return uniqueNumbers
}
/**
* Converts a given bitmap to an encoded Base64 string for sending to desktop
* @param bitmap bitmap to be encoded into string*
* @return Returns the Base64 encoded string
*/
fun bitMapToBase64(bitmap: Bitmap): String? {
val byteArrayOutputStream = ByteArrayOutputStream()
// The below line is not really compressing to PNG so much as encoding as PNG, since PNG is lossless
val isCompressed = bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
if (isCompressed) {
val b = byteArrayOutputStream.toByteArray()
val encodedString = Base64.encodeToString(b, Base64.DEFAULT)
return encodedString
}
return null
}
/**
* Reads the image files attached with an MMS from MMS database
* @param context Context in which the method is called
* @param id part ID of the image file attached with an MMS message
* @return Returns the image as a bitmap
*/
fun getMmsImage(context: Context, id: Long): Bitmap? {
val partURI = ContentUris.withAppendedId(SMSHelper.mMSPartUri, id)
var bitmap: Bitmap? = null
try {
context.contentResolver.openInputStream(partURI).use { inputStream ->
bitmap = BitmapFactory.decodeStream(inputStream)
}
} catch (e: IOException) {
Log.e("SmsMmsUtils", "Exception", e)
}
return bitmap
}
/**
* This method loads the byteArray of attachment file stored in the MMS database
* @param context Context in which the method is called
* @param id part ID of the particular multimedia attachment file of MMS
* @return returns the byteArray of the attachment
*/
fun loadAttachment(context: Context, id: Long): ByteArray {
val partURI = ContentUris.withAppendedId(SMSHelper.mMSPartUri, id)
var byteArray = ByteArray(0)
// Open inputStream from the specified URI
try {
context.contentResolver.openInputStream(partURI).use { inputStream ->
// Try read from the InputStream
if (inputStream != null) {
byteArray = IOUtils.toByteArray(inputStream)
}
}
} catch (e: IOException) {
e.printStackTrace()
}
return byteArray
}
/**
* Create a SMS attachment packet using the partID of the file requested by the device
*/
fun partIdToMessageAttachmentPacket(context: Context, partID: Long, filename: String?, type: String): NetworkPacket? {
val attachment = loadAttachment(context, partID)
val size = attachment.size.toLong()
if (size == 0L) {
Log.e("SmsMmsUtils", "Loaded attachment is empty.")
}
try {
val inputStream: InputStream = ByteArrayInputStream(attachment)
val np = NetworkPacket(type)
np["filename"] = filename
np.payload = NetworkPacket.Payload(inputStream, size)
return np
} catch (e: Exception) {
return null
}
}
/**
* Marks a conversation as read in the database.
*
* @param context the context to get the content provider with.
* @param recipients the phone numbers to find the conversation with.
*/
fun markConversationRead(context: Context, recipients: HashSet<String?>) {
thread {
try {
val threadId = Utils.getOrCreateThreadId(context, recipients)
markAsRead(context, ContentUris.withAppendedId(Telephony.Threads.CONTENT_URI, threadId), threadId)
} catch (e: Exception) {
// the conversation doesn't exist
e.printStackTrace()
}
}.start()
}
private fun markAsRead(context: Context?, uri: Uri?, threadId: Long) {
Log.v("SMSPlugin", "marking thread with threadId $threadId as read at Uri$uri")
if (uri != null && context != null) {
val values = ContentValues(2)
values.put("read", 1)
values.put("seen", 1)
context.contentResolver.update(uri, values, "(read=0 OR seen=0)", null)
}
}
}

View File

@@ -23,6 +23,7 @@ import androidx.core.content.FileProvider;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.Helpers.NotificationHelper;
import org.kde.kdeconnect_tp.BuildConfig;
import org.kde.kdeconnect_tp.R;
import java.io.File;
@@ -145,7 +146,7 @@ class ReceiveNotification {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && "file".equals(destinationUri.getScheme())) {
//Nougat and later require "content://" uris instead of "file://" uris
File file = new File(destinationUri.getPath());
Uri contentUri = FileProvider.getUriForFile(device.getContext(), "org.kde.kdeconnect_tp.fileprovider", file);
Uri contentUri = FileProvider.getUriForFile(device.getContext(), BuildConfig.APPLICATION_ID+".fileprovider", file);
intent.setDataAndType(contentUri, mimeType);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
shareIntent.putExtra(Intent.EXTRA_STREAM, contentUri);

View File

@@ -15,16 +15,19 @@ import android.view.MenuInflater;
import android.view.MenuItem;
import android.webkit.URLUtil;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.preference.PreferenceManager;
import org.kde.kdeconnect.BackgroundService;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.Helpers.WindowHelper;
import org.kde.kdeconnect.KdeConnect;
import org.kde.kdeconnect.UserInterface.List.EntryItemWithIcon;
import org.kde.kdeconnect.UserInterface.List.ListAdapter;
import org.kde.kdeconnect.UserInterface.List.SectionItem;
import org.kde.kdeconnect.base.BaseActivity;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.ActivityShareBinding;
@@ -34,11 +37,27 @@ import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public class ShareActivity extends AppCompatActivity {
import kotlin.Lazy;
import kotlin.LazyKt;
public class ShareActivity extends BaseActivity<ActivityShareBinding> {
private static final String KEY_UNREACHABLE_URL_LIST = "key_unreachable_url_list";
private ActivityShareBinding binding;
private SharedPreferences mSharedPrefs;
private final Lazy<ActivityShareBinding> lazyBinding = LazyKt.lazy(() -> ActivityShareBinding.inflate(getLayoutInflater()));
@NonNull
@Override
public ActivityShareBinding getBinding() {
return lazyBinding.getValue();
}
@Override
public boolean isScrollable() {
return true;
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
MenuInflater inflater = getMenuInflater();
@@ -59,9 +78,9 @@ public class ShareActivity extends AppCompatActivity {
private void refreshDevicesAction() {
BackgroundService.ForceRefreshConnections(this);
binding.devicesListLayout.refreshListLayout.setRefreshing(true);
binding.devicesListLayout.refreshListLayout.postDelayed(() -> {
binding.devicesListLayout.refreshListLayout.setRefreshing(false);
getBinding().devicesListLayout.refreshListLayout.setRefreshing(true);
getBinding().devicesListLayout.refreshListLayout.postDelayed(() -> {
getBinding().devicesListLayout.refreshListLayout.setRefreshing(false);
}, 1500);
}
@@ -100,8 +119,8 @@ public class ShareActivity extends AppCompatActivity {
}
}
binding.devicesListLayout.devicesList.setAdapter(new ListAdapter(ShareActivity.this, items));
binding.devicesListLayout.devicesList.setOnItemClickListener((adapterView, view, i, l) -> {
getBinding().devicesListLayout.devicesList.setAdapter(new ListAdapter(ShareActivity.this, items));
getBinding().devicesListLayout.devicesList.setOnItemClickListener((adapterView, view, i, l) -> {
Device device = devicesList.get(i - 1); //NOTE: -1 because of the title!
SharePlugin plugin = KdeConnect.getInstance().getDevicePlugin(device.getDeviceId(), SharePlugin.class);
if (intentHasUrl && !device.isReachable()) {
@@ -140,20 +159,19 @@ public class ShareActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityShareBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
mSharedPrefs = PreferenceManager.getDefaultSharedPreferences (this);
setSupportActionBar(binding.toolbarLayout.toolbar);
setSupportActionBar(getBinding().toolbarLayout.toolbar);
Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setDisplayShowHomeEnabled(true);
ActionBar actionBar = getSupportActionBar();
binding.devicesListLayout.refreshListLayout.setOnRefreshListener(this::refreshDevicesAction);
getBinding().devicesListLayout.refreshListLayout.setOnRefreshListener(this::refreshDevicesAction);
if (actionBar != null) {
actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_HOME | ActionBar.DISPLAY_SHOW_TITLE | ActionBar.DISPLAY_SHOW_CUSTOM);
}
WindowHelper.setupBottomPadding(getBinding().devicesListLayout.devicesList);
}
@Override

View File

@@ -18,11 +18,13 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import org.kde.kdeconnect.UserInterface.List.ListAdapter
import org.kde.kdeconnect.UserInterface.MainActivity
import org.kde.kdeconnect.extensions.setupBottomPadding
import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.FragmentAboutBinding
class AboutFragment : Fragment() {
private var binding: FragmentAboutBinding? = null
private var _binding: FragmentAboutBinding? = null
private val binding get() = _binding!!
private lateinit var aboutData: AboutData
private var tapCount = 0
private var firstTapMillis: Long? = null
@@ -46,23 +48,28 @@ class AboutFragment : Fragment() {
}
aboutData = requireArguments().getParcelable("ABOUT_DATA")!!
binding = FragmentAboutBinding.inflate(inflater, container, false)
_binding = FragmentAboutBinding.inflate(inflater, container, false)
updateData()
return binding!!.root
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.scrollView.setupBottomPadding()
}
@SuppressLint("SetTextI18n")
fun updateData() {
// Update general info
binding!!.appName.text = aboutData.name
binding!!.appIcon.setImageDrawable(this.context?.let { ContextCompat.getDrawable(it, aboutData.icon) })
binding!!.appVersion.text = this.context?.getString(R.string.version, aboutData.versionName)
binding.appName.text = aboutData.name
binding.appIcon.setImageDrawable(this.context?.let { ContextCompat.getDrawable(it, aboutData.icon) })
binding.appVersion.text = this.context?.getString(R.string.version, aboutData.versionName)
// Setup Easter Egg onClickListener
binding!!.generalInfoCard.setOnClickListener {
binding.generalInfoCard.setOnClickListener {
if (firstTapMillis == null) {
firstTapMillis = System.currentTimeMillis()
}
@@ -80,24 +87,24 @@ class AboutFragment : Fragment() {
// Update button onClickListeners
setupInfoButton(aboutData.bugURL, binding!!.reportBugButton)
setupInfoButton(aboutData.donateURL, binding!!.donateButton)
setupInfoButton(aboutData.sourceCodeURL, binding!!.sourceCodeButton)
setupInfoButton(aboutData.bugURL, binding.reportBugButton)
setupInfoButton(aboutData.donateURL, binding.donateButton)
setupInfoButton(aboutData.sourceCodeURL, binding.sourceCodeButton)
binding!!.licensesButton.setOnClickListener {
binding.licensesButton.setOnClickListener {
startActivity(Intent(context, LicensesActivity::class.java))
}
binding!!.aboutKdeButton.setOnClickListener {
binding.aboutKdeButton.setOnClickListener {
startActivity(Intent(context, AboutKDEActivity::class.java))
}
setupInfoButton(aboutData.websiteURL, binding!!.websiteButton)
setupInfoButton(aboutData.websiteURL, binding.websiteButton)
// Update authors
binding!!.authorsList.adapter = ListAdapter(this.requireContext(), aboutData.authors.map { AboutPersonEntryItem(it) }, false)
binding.authorsList.adapter = ListAdapter(this.requireContext(), aboutData.authors.map { AboutPersonEntryItem(it) }, false)
if (aboutData.authorsFooterText != null) {
binding!!.authorsFooterText.text = context?.getString(aboutData.authorsFooterText!!)
binding.authorsFooterText.text = context?.getString(aboutData.authorsFooterText!!)
}
}
@@ -113,6 +120,6 @@ class AboutFragment : Fragment() {
override fun onDestroyView() {
super.onDestroyView()
binding = null
_binding = null
}
}

View File

@@ -10,17 +10,21 @@ import android.os.Bundle
import android.text.Html
import android.text.Spanned
import android.text.method.LinkMovementMethod
import androidx.appcompat.app.AppCompatActivity
import org.kde.kdeconnect.base.BaseActivity
import org.kde.kdeconnect.extensions.setupBottomPadding
import org.kde.kdeconnect.extensions.viewBinding
import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.ActivityAboutKdeBinding
class AboutKDEActivity : AppCompatActivity() {
class AboutKDEActivity : BaseActivity<ActivityAboutKdeBinding>() {
override val binding: ActivityAboutKdeBinding by viewBinding(ActivityAboutKdeBinding::inflate)
override val isScrollable: Boolean = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityAboutKdeBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbarLayout.toolbar)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
supportActionBar!!.setDisplayShowHomeEnabled(true)
@@ -34,6 +38,8 @@ class AboutKDEActivity : AppCompatActivity() {
binding.reportBugsOrWishesTextView.movementMethod = LinkMovementMethod.getInstance()
binding.joinKdeTextView.movementMethod = LinkMovementMethod.getInstance()
binding.supportKdeTextView.movementMethod = LinkMovementMethod.getInstance()
binding.scrollView.setupBottomPadding()
}
private fun fromHtml(html: String): Spanned {

View File

@@ -10,23 +10,26 @@ import android.os.Bundle
import android.util.DisplayMetrics
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import org.apache.commons.io.IOUtils
import org.kde.kdeconnect.base.BaseActivity
import org.kde.kdeconnect.extensions.setupBottomPadding
import org.kde.kdeconnect.extensions.viewBinding
import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.ActivityLicensesBinding
import java.nio.charset.Charset
class LicensesActivity : AppCompatActivity() {
private lateinit var binding: ActivityLicensesBinding
class LicensesActivity : BaseActivity<ActivityLicensesBinding>() {
override val binding: ActivityLicensesBinding by viewBinding(ActivityLicensesBinding::inflate)
override val isScrollable: Boolean = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLicensesBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.licensesText.setupBottomPadding()
setSupportActionBar(binding.toolbarLayout.toolbar)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
supportActionBar!!.setDisplayShowHomeEnabled(true)

View File

@@ -7,15 +7,16 @@
package org.kde.kdeconnect.UserInterface;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.text.TextUtils;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.TooltipCompat;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -25,17 +26,21 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.BaseTransientBottomBar;
import com.google.android.material.snackbar.Snackbar;
import org.kde.kdeconnect.DeviceHost;
import org.kde.kdeconnect.Helpers.WindowHelper;
import org.kde.kdeconnect.base.BaseActivity;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.ActivityCustomDevicesBinding;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Objects;
//TODO: Require wifi connection so entries can be verified
//TODO: Resolve to ip address and don't allow unresolvable or duplicates based on ip address
//TODO: Sort the list
public class CustomDevicesActivity extends AppCompatActivity implements CustomDevicesAdapter.Callback {
import kotlin.Lazy;
import kotlin.LazyKt;
import kotlin.Unit;
public class CustomDevicesActivity extends BaseActivity<ActivityCustomDevicesBinding> implements CustomDevicesAdapter.Callback {
private static final String TAG_ADD_DEVICE_DIALOG = "AddDeviceDialog";
private static final String KEY_CUSTOM_DEVLIST_PREFERENCE = "device_list_preference";
@@ -45,37 +50,43 @@ public class CustomDevicesActivity extends AppCompatActivity implements CustomDe
private RecyclerView recyclerView;
private TextView emptyListMessage;
private ArrayList<String> customDeviceList;
private ArrayList<DeviceHost> customDeviceList;
private EditTextAlertDialogFragment addDeviceDialog;
private SharedPreferences sharedPreferences;
private CustomDevicesAdapter customDevicesAdapter;
private DeletedCustomDevice lastDeletedCustomDevice;
private int editingDeviceAtPosition;
private final Lazy<ActivityCustomDevicesBinding> lazyBinding = LazyKt.lazy(() -> ActivityCustomDevicesBinding.inflate(getLayoutInflater()));
@NonNull
@Override
protected ActivityCustomDevicesBinding getBinding() {
return lazyBinding.getValue();
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final ActivityCustomDevicesBinding binding = ActivityCustomDevicesBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
recyclerView = getBinding().recyclerView;
emptyListMessage = getBinding().emptyListMessage;
final FloatingActionButton fab = getBinding().floatingActionButton;
recyclerView = binding.recyclerView;
emptyListMessage = binding.emptyListMessage;
final FloatingActionButton fab = binding.floatingActionButton;
setSupportActionBar(binding.toolbarLayout.toolbar);
setSupportActionBar(getBinding().toolbarLayout.toolbar);
Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setDisplayShowHomeEnabled(true);
fab.setOnClickListener(v -> showEditTextDialog(""));
fab.setOnClickListener(v -> showEditTextDialog(null));
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
customDeviceList = getCustomDeviceList(sharedPreferences);
customDeviceList = getCustomDeviceList(this);
customDeviceList.forEach(host -> host.checkReachable(() -> {
runOnUiThread(() -> customDevicesAdapter.notifyDataSetChanged());
return Unit.INSTANCE;
}));
showEmptyListMessageIfRequired();
customDevicesAdapter = new CustomDevicesAdapter(this);
customDevicesAdapter = new CustomDevicesAdapter(this, getApplicationContext());
customDevicesAdapter.setCustomDevices(customDeviceList);
recyclerView.setHasFixedSize(true);
@@ -83,6 +94,9 @@ public class CustomDevicesActivity extends AppCompatActivity implements CustomDe
recyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
recyclerView.setAdapter(customDevicesAdapter);
WindowHelper.setupBottomPadding(recyclerView);
WindowHelper.setupBottomMargin(getBinding().floatingActionButton);
addDeviceDialog = (EditTextAlertDialogFragment) getSupportFragmentManager().findFragmentByTag(TAG_ADD_DEVICE_DIALOG);
if (addDeviceDialog != null) {
addDeviceDialog.setCallback(new AddDeviceDialogCallback());
@@ -108,7 +122,11 @@ public class CustomDevicesActivity extends AppCompatActivity implements CustomDe
emptyListMessage.setVisibility(customDeviceList.isEmpty() ? View.VISIBLE : View.GONE);
}
private void showEditTextDialog(@NonNull String text) {
private void showEditTextDialog(DeviceHost deviceHost) {
String text = "";
if (deviceHost != null) {
text = deviceHost.toString();
}
addDeviceDialog = new EditTextAlertDialogFragment.Builder()
.setTitle(R.string.add_device_dialog_title)
.setHint(R.string.add_device_hint)
@@ -122,6 +140,7 @@ public class CustomDevicesActivity extends AppCompatActivity implements CustomDe
}
private void saveList() {
SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
String serialized = TextUtils.join(IP_DELIM, customDeviceList);
sharedPreferences
.edit()
@@ -129,30 +148,38 @@ public class CustomDevicesActivity extends AppCompatActivity implements CustomDe
.apply();
}
private static ArrayList<String> deserializeIpList(String serialized) {
ArrayList<String> ipList = new ArrayList<>();
private static ArrayList<DeviceHost> deserializeIpList(String serialized) {
ArrayList<DeviceHost> ipList = new ArrayList<>();
if (!serialized.isEmpty()) {
Collections.addAll(ipList, serialized.split(IP_DELIM));
for (String ip: serialized.split(IP_DELIM)) {
DeviceHost deviceHost = DeviceHost.toDeviceHostOrNull(ip);
// To prevent crashes when migrating if invalid hosts are present
if (deviceHost != null) {
ipList.add(deviceHost);
}
}
}
return ipList;
}
public static ArrayList<String> getCustomDeviceList(SharedPreferences sharedPreferences) {
public static ArrayList<DeviceHost> getCustomDeviceList(Context context) {
SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
String deviceListPrefs = sharedPreferences.getString(KEY_CUSTOM_DEVLIST_PREFERENCE, "");
return deserializeIpList(deviceListPrefs);
ArrayList<DeviceHost> list = deserializeIpList(deviceListPrefs);
list.sort(Comparator.comparing(DeviceHost::toString));
return list;
}
@Override
public void onCustomDeviceClicked(String customDevice) {
public void onCustomDeviceClicked(DeviceHost customDevice) {
editingDeviceAtPosition = customDeviceList.indexOf(customDevice);
showEditTextDialog(customDevice);
}
@Override
public void onCustomDeviceDismissed(String customDevice) {
public void onCustomDeviceDismissed(DeviceHost customDevice) {
lastDeletedCustomDevice = new DeletedCustomDevice(customDevice, customDeviceList.indexOf(customDevice));
customDeviceList.remove(lastDeletedCustomDevice.position);
customDevicesAdapter.notifyItemRemoved(lastDeletedCustomDevice.position);
@@ -190,20 +217,45 @@ public class CustomDevicesActivity extends AppCompatActivity implements CustomDe
public void onPositiveButtonClicked() {
if (addDeviceDialog.editText.getText() != null) {
String deviceNameOrIP = addDeviceDialog.editText.getText().toString().trim();
DeviceHost host = DeviceHost.toDeviceHostOrNull(deviceNameOrIP);
// don't add empty string (after trimming)
if (!deviceNameOrIP.isEmpty() && !customDeviceList.contains(deviceNameOrIP)) {
if (host != null) {
if (!customDeviceList.stream().anyMatch(h -> h.toString().equals(host.toString()))) {
if (editingDeviceAtPosition >= 0) {
customDeviceList.set(editingDeviceAtPosition, deviceNameOrIP);
customDeviceList.set(editingDeviceAtPosition, host);
customDevicesAdapter.notifyItemChanged(editingDeviceAtPosition);
} else {
customDeviceList.add(deviceNameOrIP);
customDevicesAdapter.notifyItemInserted(customDeviceList.size() - 1);
host.checkReachable(() -> {
runOnUiThread(() -> customDevicesAdapter.notifyItemChanged(editingDeviceAtPosition));
return Unit.INSTANCE;
});
}
else {
// Find insertion position to ensure list remains sorted
int pos = 0;
while (customDeviceList.size() - 1 >= pos && customDeviceList.get(pos).toString().compareTo(host.toString()) < 0) {
pos++;
}
final int position = pos;
customDeviceList.add(position, host);
customDevicesAdapter.notifyItemInserted(pos);
host.checkReachable(() -> {
runOnUiThread(() -> customDevicesAdapter.notifyItemChanged(position));
return Unit.INSTANCE;
});
}
saveList();
showEmptyListMessageIfRequired();
}
else {
Toast.makeText(addDeviceDialog.getContext(), R.string.device_host_duplicate, Toast.LENGTH_SHORT).show();
}
}
else {
Toast.makeText(addDeviceDialog.getContext(), R.string.device_host_invalid, Toast.LENGTH_SHORT).show();
}
}
}
@@ -214,10 +266,10 @@ public class CustomDevicesActivity extends AppCompatActivity implements CustomDe
}
private static class DeletedCustomDevice {
@NonNull String hostnameOrIP;
@NonNull DeviceHost hostnameOrIP;
int position;
DeletedCustomDevice(@NonNull String hostnameOrIP, int position) {
DeletedCustomDevice(@NonNull DeviceHost hostnameOrIP, int position) {
this.hostnameOrIP = hostnameOrIP;
this.position = position;
}
@@ -228,4 +280,9 @@ public class CustomDevicesActivity extends AppCompatActivity implements CustomDe
super.onBackPressed();
return true;
}
@Override
public boolean isScrollable() {
return true;
}
}

View File

@@ -6,6 +6,7 @@
package org.kde.kdeconnect.UserInterface;
import android.content.Context;
import android.graphics.Canvas;
import android.view.LayoutInflater;
import android.view.View;
@@ -16,21 +17,25 @@ import androidx.annotation.Nullable;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import org.kde.kdeconnect.DeviceHost;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.CustomDeviceItemBinding;
import java.util.ArrayList;
public class CustomDevicesAdapter extends RecyclerView.Adapter<CustomDevicesAdapter.ViewHolder> {
private ArrayList<String> customDevices;
private ArrayList<DeviceHost> customDevices;
private final Callback callback;
private final Context context;
CustomDevicesAdapter(@NonNull Callback callback) {
CustomDevicesAdapter(@NonNull Callback callback, Context context) {
this.callback = callback;
this.context = context;
customDevices = new ArrayList<>();
}
void setCustomDevices(ArrayList<String> customDevices) {
void setCustomDevices(ArrayList<DeviceHost> customDevices) {
this.customDevices = customDevices;
notifyDataSetChanged();
@@ -51,12 +56,13 @@ public class CustomDevicesAdapter extends RecyclerView.Adapter<CustomDevicesAdap
CustomDeviceItemBinding itemBinding =
CustomDeviceItemBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new ViewHolder(itemBinding);
return new ViewHolder(itemBinding, context);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(customDevices.get(position));
DeviceHost deviceHost = customDevices.get(position);
holder.bind(deviceHost.toString(), deviceHost.getPing());
}
@Override
@@ -66,15 +72,29 @@ public class CustomDevicesAdapter extends RecyclerView.Adapter<CustomDevicesAdap
class ViewHolder extends RecyclerView.ViewHolder implements SwipeableViewHolder {
private final CustomDeviceItemBinding itemBinding;
private final Context context;
ViewHolder(@NonNull CustomDeviceItemBinding itemBinding) {
ViewHolder(@NonNull CustomDeviceItemBinding itemBinding, Context context) {
super(itemBinding.getRoot());
this.itemBinding = itemBinding;
itemBinding.deviceNameOrIP.setOnClickListener(v -> callback.onCustomDeviceClicked(customDevices.get(getAdapterPosition())));
this.context = context;
}
void bind(String customDevice) {
void bind(String customDevice, DeviceHost.PingResult pingResult) {
itemBinding.deviceNameOrIP.setText(customDevice);
if (pingResult != null) {
if (pingResult.getLatency() != null) {
String text = context.getString(R.string.ping_result, pingResult.getLatency());
itemBinding.connectionStatus.setText(text);
}
else {
itemBinding.connectionStatus.setText(R.string.ping_failed);
}
}
else {
itemBinding.connectionStatus.setText(R.string.ping_in_progress);
}
}
@Override
@@ -144,7 +164,7 @@ public class CustomDevicesAdapter extends RecyclerView.Adapter<CustomDevicesAdap
}
public interface Callback {
void onCustomDeviceClicked(String customDevice);
void onCustomDeviceDismissed(String customDevice);
void onCustomDeviceClicked(DeviceHost customDevice);
void onCustomDeviceDismissed(DeviceHost customDevice);
}
}

View File

@@ -44,6 +44,7 @@ import org.kde.kdeconnect.Plugins.Plugin
import org.kde.kdeconnect.Plugins.PresenterPlugin.PresenterPlugin
import org.kde.kdeconnect.Plugins.RunCommandPlugin.RunCommandPlugin
import org.kde.kdeconnect.UserInterface.compose.KdeTheme
import org.kde.kdeconnect.extensions.setupBottomPadding
import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.ActivityDeviceBinding
import org.kde.kdeconnect_tp.databinding.ViewPairErrorBinding
@@ -114,16 +115,9 @@ class DeviceFragment : Fragment() {
this.refreshDevicesAction()
}
requirePairingBinding().pairVerification.text = SslHelper.getVerificationKey(SslHelper.certificate, device?.certificate)
requirePairingBinding().pairButton.setOnClickListener {
with(requirePairingBinding()) {
pairButton.visibility = View.GONE
pairMessage.text = getString(R.string.pair_requested)
pairProgress.visibility = View.VISIBLE
}
device?.requestPairing()
mActivity?.invalidateOptionsMenu()
refreshUI()
}
requirePairingBinding().acceptButton.setOnClickListener {
device?.apply {
@@ -156,6 +150,11 @@ class DeviceFragment : Fragment() {
return deviceBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
deviceBinding?.deviceView?.setupBottomPadding()
}
private fun refreshDevicesAction() {
BackgroundService.ForceRefreshConnections(requireContext())
requireErrorBinding().errorMessageContainer.isRefreshing = true
@@ -213,6 +212,10 @@ class DeviceFragment : Fragment() {
requireContext().resources.getString(R.string.remote_device_fingerprint)
} \n ${
SslHelper.getCertificateHash(device.certificate)
} \n\n ${
requireContext().resources.getString(R.string.protocol_version)
} ${
device.protocolVersion
}"
)
menu.add(R.string.encryption_info_title).setOnMenuItemClickListener {
@@ -232,7 +235,7 @@ class DeviceFragment : Fragment() {
true
}
}
if (device.isPairRequested) {
if (device.pairStatus == PairingHandler.PairState.Requested) {
menu.add(R.string.cancel_pairing).setOnMenuItemClickListener {
device.cancelPairing()
true
@@ -265,19 +268,36 @@ class DeviceFragment : Fragment() {
//Once in-app, there is no point in keep displaying the notification if any
device.hidePairingNotification()
if (device.isPairRequestedByPeer) {
when (device.pairStatus) {
PairingHandler.PairState.NotPaired -> {
requireErrorBinding().errorMessageContainer.visibility = View.GONE
requireDeviceBinding().deviceView.visibility = View.GONE
requirePairingBinding().pairingButtons.visibility = View.VISIBLE
requirePairingBinding().pairVerification.visibility = View.GONE
}
PairingHandler.PairState.Requested -> {
with(requirePairingBinding()) {
pairButton.visibility = View.GONE
pairMessage.text = getString(R.string.pair_requested)
pairProgress.visibility = View.VISIBLE
pairVerification.text = device.verificationKey
pairVerification.visibility = View.VISIBLE
}
}
PairingHandler.PairState.RequestedByPeer -> {
with (requirePairingBinding()) {
pairMessage.setText(R.string.pair_requested)
pairVerification.visibility = View.VISIBLE
pairVerification.text = SslHelper.getVerificationKey(SslHelper.certificate, device.certificate)
pairingButtons.visibility = View.VISIBLE
pairProgress.visibility = View.GONE
pairButton.visibility = View.GONE
pairRequestButtons.visibility = View.VISIBLE
pairVerification.text = device.verificationKey
pairVerification.visibility = View.VISIBLE
}
requireDeviceBinding().deviceView.visibility = View.GONE
} else {
if (device.isPaired) {
}
PairingHandler.PairState.Paired -> {
requirePairingBinding().pairingButtons.visibility = View.GONE
if (device.isReachable) {
val context = requireContext()
@@ -295,14 +315,10 @@ class DeviceFragment : Fragment() {
requireErrorBinding().errorMessageContainer.visibility = View.VISIBLE
requireDeviceBinding().deviceView.visibility = View.GONE
}
} else {
requireErrorBinding().errorMessageContainer.visibility = View.GONE
requireDeviceBinding().deviceView.visibility = View.GONE
requirePairingBinding().pairingButtons.visibility = View.VISIBLE
}
}
mActivity?.invalidateOptionsMenu()
}
}
private val pairingCallback: PairingHandler.PairingCallback = object : PairingHandler.PairingCallback {
override fun incomingPairRequest() {

View File

@@ -1,45 +0,0 @@
/*
* SPDX-FileCopyrightText: 2014 Albert Vaca Cintora <albertvaka@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
package org.kde.kdeconnect.UserInterface.List
import android.R
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
class SmallEntryItem : ListAdapter.Item {
private val title: String
private val clickListener: View.OnClickListener?
constructor(title: String, clickListener: View.OnClickListener?) {
this.title = title
this.clickListener = clickListener
}
override fun inflateView(layoutInflater: LayoutInflater): View {
val v = layoutInflater.inflate(R.layout.simple_list_item_1, null)
val padding = (28 * layoutInflater.context.resources.displayMetrics.density).toInt()
v.setPadding(padding, 0, padding, 0)
val titleView = v.findViewById<TextView>(R.id.text1)
if (titleView != null) {
titleView.text = title
if (clickListener != null) {
titleView.setOnClickListener(clickListener)
val outValue = TypedValue()
layoutInflater.context.theme.resolveAttribute(
R.attr.selectableItemBackground,
outValue,
true
)
v.setBackgroundResource(outValue.resourceId)
}
}
return v
}
}

View File

@@ -32,6 +32,7 @@ import androidx.fragment.app.Fragment;
import org.kde.kdeconnect.BackgroundService;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.Helpers.TrustedNetworkHelper;
import org.kde.kdeconnect.Helpers.WindowHelper;
import org.kde.kdeconnect.KdeConnect;
import org.kde.kdeconnect.UserInterface.List.ListAdapter;
import org.kde.kdeconnect.UserInterface.List.PairingDeviceItem;
@@ -118,6 +119,7 @@ public class PairingFragment extends Fragment implements PairingDeviceItem.Callb
// Configure focus order for Accessibility, for touchpads, and for TV remotes
// (allow focus of items in the device list)
devicesListBinding.devicesList.setItemsCanFocus(true);
WindowHelper.setupBottomPadding(devicesListBinding.devicesList);
}
@Override

View File

@@ -13,8 +13,8 @@ import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
@@ -24,12 +24,17 @@ import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.DeviceStats;
import org.kde.kdeconnect.KdeConnect;
import org.kde.kdeconnect.Plugins.Plugin;
import org.kde.kdeconnect.base.BaseActivity;
import org.kde.kdeconnect_tp.R;
import org.kde.kdeconnect_tp.databinding.ActivityPluginSettingsBinding;
import java.util.Objects;
import kotlin.Lazy;
import kotlin.LazyKt;
public class PluginSettingsActivity
extends AppCompatActivity
extends BaseActivity<ActivityPluginSettingsBinding>
implements PluginPreference.PluginPreferenceCallback {
public static final String EXTRA_DEVICE_ID = "deviceId";
@@ -38,12 +43,18 @@ public class PluginSettingsActivity
//TODO: Save/restore state
static private String deviceId; //Static because if we get here by using the back button in the action bar, the extra deviceId will not be set.
private final Lazy<ActivityPluginSettingsBinding> lazyBinding = LazyKt.lazy(() -> ActivityPluginSettingsBinding.inflate(getLayoutInflater()));
@NonNull
@Override
protected ActivityPluginSettingsBinding getBinding() {
return lazyBinding.getValue();
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_plugin_settings);
setSupportActionBar(findViewById(R.id.toolbar));
Objects.requireNonNull(getSupportActionBar()).setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setDisplayShowHomeEnabled(true);

View File

@@ -6,6 +6,7 @@
package org.kde.kdeconnect.UserInterface
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@@ -33,6 +34,7 @@ import org.kde.kdeconnect.Helpers.DeviceHelper.filterName
import org.kde.kdeconnect.Helpers.DeviceHelper.getDeviceName
import org.kde.kdeconnect.Helpers.NotificationHelper
import org.kde.kdeconnect.UserInterface.ThemeUtil.applyTheme
import org.kde.kdeconnect.extensions.setupBottomPadding
import org.kde.kdeconnect_tp.R
class SettingsFragment : PreferenceFragmentCompat() {
@@ -61,6 +63,11 @@ class SettingsFragment : PreferenceFragmentCompat() {
preferenceScreen = screen
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
listView.setupBottomPadding()
}
private fun deviceNamePref(context: Context) = EditTextPreference(context).apply {
key = DeviceHelper.KEY_DEVICE_NAME_PREFERENCE
isSelectable = true
@@ -146,21 +153,43 @@ class SettingsFragment : PreferenceFragmentCompat() {
setTitle(R.string.trusted_networks)
setSummary(R.string.trusted_networks_desc)
onPreferenceClickListener = Preference.OnPreferenceClickListener {
startActivity(Intent(context, TrustedNetworksActivity::class.java))
startActivityForResult(Intent(context, TrustedNetworksActivity::class.java), REQUEST_REFRESH_NETWORKS)
true
}
}
private val REQUEST_REFRESH_DEVICES_BY_IP = 1
private val REQUEST_REFRESH_NETWORKS = 2
private lateinit var devicesByIpPref : Preference
/** Opens activity to configure device by IP when clicked */
private fun devicesByIpPref(context: Context) = Preference(context).apply {
devicesByIpPref = this
isPersistent = false
setTitle(R.string.custom_device_list)
updateDevicesByIpSummary()
onPreferenceClickListener = Preference.OnPreferenceClickListener {
startActivity(Intent(context, CustomDevicesActivity::class.java))
startActivityForResult(Intent(context, CustomDevicesActivity::class.java), REQUEST_REFRESH_DEVICES_BY_IP)
true
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_REFRESH_DEVICES_BY_IP -> updateDevicesByIpSummary()
REQUEST_REFRESH_NETWORKS -> BackgroundService.instance?.onNetworkChange(null)
else -> super.onActivityResult(requestCode, resultCode, data)
}
}
private fun updateDevicesByIpSummary() {
devicesByIpPref.setSummary(getString(
R.string.custom_devices_settings_summary,
CustomDevicesActivity.getCustomDeviceList(context).size
))
}
private fun udpBroadcastPref(context: Context) = SwitchPreference(context).apply {
setDefaultValue(true)
key = KEY_UDP_BROADCAST_ENABLED

View File

@@ -12,13 +12,14 @@ import android.view.View
import android.widget.*
import android.widget.AdapterView.OnItemClickListener
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import org.kde.kdeconnect.Helpers.TrustedNetworkHelper
import org.kde.kdeconnect.base.BaseActivity
import org.kde.kdeconnect.extensions.viewBinding
import org.kde.kdeconnect_tp.R
import org.kde.kdeconnect_tp.databinding.TrustedNetworkListBinding
class TrustedNetworksActivity : AppCompatActivity() {
lateinit var binding: TrustedNetworkListBinding
class TrustedNetworksActivity : BaseActivity<TrustedNetworkListBinding>() {
override val binding: TrustedNetworkListBinding by viewBinding(TrustedNetworkListBinding::inflate)
private val trustedNetworks: MutableList<String> = mutableListOf()
private val trustedNetworksView: ListView
@@ -39,9 +40,6 @@ class TrustedNetworksActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = TrustedNetworkListBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbarLayout.toolbar)
supportActionBar!!.apply {
setDisplayHomeAsUpEnabled(true)

View File

@@ -8,6 +8,7 @@ package org.kde.kdeconnect.UserInterface.compose
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@@ -19,7 +20,7 @@ import org.kde.kdeconnect_tp.R
@Composable
fun KdeTopAppBar(
title: String = stringResource(R.string.kde_connect),
navIcon: ImageVector = Icons.Default.ArrowBack,
navIcon: ImageVector = Icons.AutoMirrored.Filled.ArrowBack,
navIconDescription: String = "",
navIconOnClick: () -> Unit, // = { onBackPressedDispatcher.onBackPressed() }
actions: @Composable (RowScope.() -> Unit) = {},

Some files were not shown because too many files have changed in this diff Show More