Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47f1b2c40f | ||
| d04c1e8f77 | |||
|
|
76a19f7e02 | ||
| 3d35ea5930 | |||
| 9fe79de3b5 | |||
|
|
044631b05d | ||
| 4bc5b4d790 | |||
| da52ec84b7 | |||
|
|
f758796d83 | ||
| 77d4071728 | |||
| ca855a6945 | |||
|
|
a56662ab69 | ||
| f870fd1c2a | |||
| 70f7d41d9a |
@@ -47,7 +47,7 @@ jobs:
|
|||||||
git commit -m "chore: bump version to ${{ steps.bump.outputs.VERSION }} [skip ci]"
|
git commit -m "chore: bump version to ${{ steps.bump.outputs.VERSION }} [skip ci]"
|
||||||
git push
|
git push
|
||||||
|
|
||||||
build-android:
|
build-and-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: increment-version
|
needs: increment-version
|
||||||
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped')
|
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped')
|
||||||
@@ -56,6 +56,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
submodules: false
|
submodules: false
|
||||||
ref: ${{ github.ref_name }}
|
ref: ${{ github.ref_name }}
|
||||||
|
|
||||||
@@ -92,28 +93,6 @@ jobs:
|
|||||||
- name: Apply overlay and build Android APK
|
- name: Apply overlay and build Android APK
|
||||||
run: npm run android:apk:linux
|
run: npm run android:apk:linux
|
||||||
|
|
||||||
- name: Upload APK
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: Paarrot-Android-debug.apk
|
|
||||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
|
||||||
|
|
||||||
create-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [increment-version, build-android]
|
|
||||||
if: always() && needs.build-android.result == 'success'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Pull latest (after version bump)
|
|
||||||
if: github.ref == 'refs/heads/master'
|
|
||||||
run: git pull origin master
|
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
@@ -143,21 +122,6 @@ jobs:
|
|||||||
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
|
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
|
||||||
git push origin "v${{ steps.version.outputs.VERSION }}"
|
git push origin "v${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Prepare release files
|
|
||||||
run: |
|
|
||||||
mkdir -p release-files
|
|
||||||
find artifacts -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.nupkg" -o -name "RELEASES*" \) -exec cp {} release-files/ \;
|
|
||||||
APK=$(find artifacts -type f -name "*.apk" | head -1)
|
|
||||||
if [ -n "$APK" ]; then
|
|
||||||
cp "$APK" "release-files/Paarrot.apk"
|
|
||||||
fi
|
|
||||||
ls -la release-files/
|
|
||||||
|
|
||||||
- name: Create or Update Release
|
- name: Create or Update Release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -167,6 +131,13 @@ jobs:
|
|||||||
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
|
API_BASE="http://synbox.ruv.wtf:8418/api/v1"
|
||||||
REPO="litruv/cinny-mobile"
|
REPO="litruv/cinny-mobile"
|
||||||
|
|
||||||
|
APK=$(find android/app/build/outputs/apk/debug -name "Paarrot-*.apk" | head -1)
|
||||||
|
if [ -z "$APK" ]; then
|
||||||
|
echo "No APK found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Found APK: $APK"
|
||||||
|
|
||||||
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
|
||||||
|
|
||||||
@@ -188,16 +159,13 @@ jobs:
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Uploading assets..."
|
FILENAME="Paarrot-${VERSION}.apk"
|
||||||
for FILE in release-files/*; do
|
|
||||||
FILENAME=$(basename "$FILE")
|
|
||||||
echo "Uploading ${FILENAME}..."
|
echo "Uploading ${FILENAME}..."
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary "@${FILE}" \
|
--data-binary "@${APK}" \
|
||||||
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
|
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
|
||||||
echo ""
|
echo ""
|
||||||
done
|
|
||||||
|
|
||||||
echo "Release complete!"
|
echo "Release complete!"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.paarrot.app"
|
namespace = "com.paarrot.app"
|
||||||
@@ -22,6 +23,11 @@ android {
|
|||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
applicationVariants.all { variant ->
|
||||||
|
variant.outputs.all { output ->
|
||||||
|
outputFileName = "Paarrot-${variant.versionName}.apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
@@ -36,6 +42,7 @@ dependencies {
|
|||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
implementation project(':capacitor-android')
|
implementation project(':capacitor-android')
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||||
testImplementation "junit:junit:$junitVersion"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
|||||||
@@ -33,12 +33,29 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths"></meta-data>
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".MatrixSyncService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false"
|
||||||
|
android:stopWithTask="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".BootReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|||||||
36
android/app/src/main/java/com/paarrot/app/BootReceiver.kt
Normal file
36
android/app/src/main/java/com/paarrot/app/BootReceiver.kt
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restarts [MatrixSyncService] after the device boots, using credentials
|
||||||
|
* previously persisted by [SyncServicePlugin].
|
||||||
|
*/
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||||
|
|
||||||
|
val prefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
|
val homeserver = prefs.getString(MatrixSyncService.EXTRA_HOMESERVER, null) ?: return
|
||||||
|
val token = prefs.getString(MatrixSyncService.EXTRA_TOKEN, null) ?: return
|
||||||
|
val userId = prefs.getString(MatrixSyncService.EXTRA_USER_ID, null) ?: ""
|
||||||
|
val deviceId = prefs.getString(MatrixSyncService.EXTRA_DEVICE_ID, null) ?: ""
|
||||||
|
|
||||||
|
val serviceIntent = Intent(context, MatrixSyncService::class.java).apply {
|
||||||
|
putExtra(MatrixSyncService.EXTRA_HOMESERVER, homeserver)
|
||||||
|
putExtra(MatrixSyncService.EXTRA_TOKEN, token)
|
||||||
|
putExtra(MatrixSyncService.EXTRA_USER_ID, userId)
|
||||||
|
putExtra(MatrixSyncService.EXTRA_DEVICE_ID, deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(serviceIntent)
|
||||||
|
} else {
|
||||||
|
context.startService(serviceIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
package com.paarrot.app;
|
package com.paarrot.app;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {}
|
public class MainActivity extends BridgeActivity {
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
registerPlugin(SyncServicePlugin.class);
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
239
android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt
Normal file
239
android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent ForegroundService that maintains a Matrix /sync long-poll loop,
|
||||||
|
* showing native notifications for new messages when the main app process is alive
|
||||||
|
* but the UI is not in the foreground, and when the app has been swiped away.
|
||||||
|
*
|
||||||
|
* Start via [SyncServicePlugin]. Credentials are persisted in SharedPreferences
|
||||||
|
* so [BootReceiver] can restart it after a device reboot.
|
||||||
|
*/
|
||||||
|
class MatrixSyncService : Service() {
|
||||||
|
|
||||||
|
private val job = SupervisorJob()
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
|
/** Event IDs already shown as notifications this session — prevents duplicates on restart. */
|
||||||
|
private val shownEventIds = HashSet<String>(64)
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?) = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val homeserver = intent?.getStringExtra(EXTRA_HOMESERVER)
|
||||||
|
?: prefs.getString(EXTRA_HOMESERVER, null)
|
||||||
|
val token = intent?.getStringExtra(EXTRA_TOKEN)
|
||||||
|
?: prefs.getString(EXTRA_TOKEN, null)
|
||||||
|
val userId = intent?.getStringExtra(EXTRA_USER_ID)
|
||||||
|
?: prefs.getString(EXTRA_USER_ID, null) ?: ""
|
||||||
|
|
||||||
|
if (homeserver == null || token == null) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
startForeground(NOTIF_ID_STATUS, buildStatusNotification())
|
||||||
|
|
||||||
|
serviceScope.launch {
|
||||||
|
runSyncLoop(homeserver, token, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runSyncLoop(homeserver: String, token: String, userId: String) {
|
||||||
|
val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
var since = prefs.getString(KEY_SINCE, null)
|
||||||
|
var isFirstSync = since == null
|
||||||
|
|
||||||
|
while (serviceScope.isActive) {
|
||||||
|
try {
|
||||||
|
val url = buildSyncUrl(homeserver.trimEnd('/'), since)
|
||||||
|
val (responseCode, body) = doHttpGet(url, token)
|
||||||
|
|
||||||
|
when (responseCode) {
|
||||||
|
200 -> {
|
||||||
|
val json = JSONObject(body ?: "{}")
|
||||||
|
val nextBatch = json.optString("next_batch").takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
if (nextBatch != null) {
|
||||||
|
prefs.edit().putString(KEY_SINCE, nextBatch).apply()
|
||||||
|
|
||||||
|
if (!isFirstSync && !appInForeground) {
|
||||||
|
processRoomEvents(json, userId)
|
||||||
|
}
|
||||||
|
isFirstSync = false
|
||||||
|
since = nextBatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
401, 403 -> {
|
||||||
|
Log.w(TAG, "Auth error $responseCode — stopping sync")
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
else -> delay(BACKOFF_ERRS_MS)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Sync error: ${e.message}")
|
||||||
|
delay(BACKOFF_ERRS_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSyncUrl(base: String, since: String?): String {
|
||||||
|
val filter = """{"room":{"timeline":{"limit":10,"types":["m.room.message"]},"state":{"types":[]},"account_data":{"types":[]},"ephemeral":{"types":[]}},"account_data":{"types":[]},"presence":{"types":[]}}"""
|
||||||
|
val encodedFilter = URLEncoder.encode(filter, "UTF-8")
|
||||||
|
val sinceParam = if (since != null) "&since=${URLEncoder.encode(since, "UTF-8")}" else ""
|
||||||
|
return "$base/_matrix/client/v3/sync?timeout=30000&filter=$encodedFilter$sinceParam"
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doHttpGet(urlString: String, token: String): Pair<Int, String?> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val conn = URL(urlString).openConnection() as HttpURLConnection
|
||||||
|
try {
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
|
conn.connectTimeout = 5_000
|
||||||
|
conn.readTimeout = 35_000
|
||||||
|
val code = conn.responseCode
|
||||||
|
val body = if (code == 200) conn.inputStream.bufferedReader().readText() else null
|
||||||
|
Pair(code, body)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Pair(-1, null)
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
|
||||||
|
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
|
||||||
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
ensureMessageChannel(nm)
|
||||||
|
|
||||||
|
val roomIds = joinedRooms.keys().asSequence().toList()
|
||||||
|
for (roomId in roomIds) {
|
||||||
|
val timeline = joinedRooms.optJSONObject(roomId)
|
||||||
|
?.optJSONObject("timeline") ?: continue
|
||||||
|
val events = timeline.optJSONArray("events") ?: continue
|
||||||
|
|
||||||
|
for (i in 0 until events.length()) {
|
||||||
|
val event = events.optJSONObject(i) ?: continue
|
||||||
|
val eventId = event.optString("event_id")
|
||||||
|
|
||||||
|
if (event.optString("type") != "m.room.message") continue
|
||||||
|
if (event.optString("sender") == myUserId) continue
|
||||||
|
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
||||||
|
|
||||||
|
val content = event.optJSONObject("content") ?: continue
|
||||||
|
val body = content.optString("body").takeIf { it.isNotBlank() } ?: continue
|
||||||
|
val sender = event.optString("sender")
|
||||||
|
val senderName = sender.substringAfter("@").substringBefore(":")
|
||||||
|
|
||||||
|
showMessageNotification(nm, senderName, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showMessageNotification(nm: NotificationManager, sender: String, body: String) {
|
||||||
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
?: Intent(this, MainActivity::class.java)
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
this, 0, launchIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||||
|
.setContentTitle(sender)
|
||||||
|
.setContentText(body)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
nm.notify(System.currentTimeMillis().toInt(), notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStatusNotification(): Notification {
|
||||||
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_STATUS, "Sync Status",
|
||||||
|
NotificationManager.IMPORTANCE_MIN,
|
||||||
|
).apply {
|
||||||
|
description = "Paarrot background sync status"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_STATUS)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||||
|
.setContentTitle("Paarrot")
|
||||||
|
.setContentText("Connected")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureMessageChannel(nm: NotificationManager) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_MESSAGES, "Messages",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply { description = "Matrix message notifications" }
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MatrixSyncService"
|
||||||
|
const val EXTRA_HOMESERVER = "homeserver_url"
|
||||||
|
const val EXTRA_TOKEN = "access_token"
|
||||||
|
const val EXTRA_USER_ID = "user_id"
|
||||||
|
const val EXTRA_DEVICE_ID = "device_id"
|
||||||
|
const val PREFS = "matrix_sync_prefs"
|
||||||
|
const val KEY_SINCE = "since_token"
|
||||||
|
private const val NOTIF_ID_STATUS = 1001
|
||||||
|
private const val CHANNEL_STATUS = "sync_status"
|
||||||
|
private const val CHANNEL_MESSAGES = "messages"
|
||||||
|
private const val BACKOFF_ERRS_MS = 10_000L
|
||||||
|
|
||||||
|
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */
|
||||||
|
@Volatile
|
||||||
|
var appInForeground = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import com.getcapacitor.Plugin
|
||||||
|
import com.getcapacitor.PluginCall
|
||||||
|
import com.getcapacitor.PluginMethod
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capacitor plugin that controls [MatrixSyncService] from JS.
|
||||||
|
*
|
||||||
|
* JS API:
|
||||||
|
* - `start({ homeserverUrl, accessToken, userId, deviceId })` — start/update sync service
|
||||||
|
* - `stop()` — stop sync service and clear persisted credentials
|
||||||
|
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
|
||||||
|
* - `getStatus()` — returns `{ running: boolean }`
|
||||||
|
*/
|
||||||
|
@CapacitorPlugin(name = "MatrixBackgroundSync")
|
||||||
|
class SyncServicePlugin : Plugin() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the [MatrixSyncService] with the provided Matrix credentials.
|
||||||
|
* Persists credentials in SharedPreferences so [BootReceiver] can restart
|
||||||
|
* the service after a device reboot.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun start(call: PluginCall) {
|
||||||
|
val homeserver = call.getString("homeserverUrl")
|
||||||
|
?: return call.reject("homeserverUrl required")
|
||||||
|
val token = call.getString("accessToken")
|
||||||
|
?: return call.reject("accessToken required")
|
||||||
|
val userId = call.getString("userId") ?: ""
|
||||||
|
val deviceId = call.getString("deviceId") ?: ""
|
||||||
|
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.putString(MatrixSyncService.EXTRA_HOMESERVER, homeserver)
|
||||||
|
.putString(MatrixSyncService.EXTRA_TOKEN, token)
|
||||||
|
.putString(MatrixSyncService.EXTRA_USER_ID, userId)
|
||||||
|
.putString(MatrixSyncService.EXTRA_DEVICE_ID, deviceId)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
val intent = Intent(context, MatrixSyncService::class.java).apply {
|
||||||
|
putExtra(MatrixSyncService.EXTRA_HOMESERVER, homeserver)
|
||||||
|
putExtra(MatrixSyncService.EXTRA_TOKEN, token)
|
||||||
|
putExtra(MatrixSyncService.EXTRA_USER_ID, userId)
|
||||||
|
putExtra(MatrixSyncService.EXTRA_DEVICE_ID, deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops the sync service and erases persisted credentials. */
|
||||||
|
@PluginMethod
|
||||||
|
fun stop(call: PluginCall) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
|
context.stopService(Intent(context, MatrixSyncService::class.java))
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies the service whether the Capacitor WebView UI is currently visible.
|
||||||
|
* When `foreground == true` the service skips firing notifications because
|
||||||
|
* the JS layer handles them directly via LocalNotifications.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun setAppForeground(call: PluginCall) {
|
||||||
|
MatrixSyncService.appInForeground = call.getBoolean("foreground", false) ?: false
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns whether the sync service is currently running. */
|
||||||
|
@PluginMethod
|
||||||
|
fun getStatus(call: PluginCall) {
|
||||||
|
val am = context.getSystemService(Context.ACTIVITY_SERVICE)
|
||||||
|
as android.app.ActivityManager
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val running = am.getRunningServices(Int.MAX_VALUE)
|
||||||
|
.any { it.service.className == MatrixSyncService::class.java.name }
|
||||||
|
call.resolve(JSObject().apply { put("running", running) })
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREFS = "sync_service_prefs"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
|
ext {
|
||||||
|
kotlin_version = '1.9.25'
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
@@ -9,6 +12,7 @@ buildscript {
|
|||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
classpath 'com.google.gms:google-services:4.4.4'
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ import {
|
|||||||
setupNotificationTapListener,
|
setupNotificationTapListener,
|
||||||
} from '../../utils/tauri';
|
} from '../../utils/tauri';
|
||||||
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
||||||
|
import {
|
||||||
|
startBackgroundSync,
|
||||||
|
stopBackgroundSync,
|
||||||
|
setAppForegroundState,
|
||||||
|
} from '../../utils/backgroundSync';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the selected emoji style font to the document.
|
* Applies the selected emoji style font to the document.
|
||||||
@@ -413,6 +418,31 @@ function MessageNotifications() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the native Android background sync service on login, keeps it
|
||||||
|
* informed of foreground state so it doesn't double-fire notifications,
|
||||||
|
* and stops it cleanly on unmount (logout).
|
||||||
|
* Only active on Android Capacitor builds.
|
||||||
|
*/
|
||||||
|
function BackgroundSyncSetup() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startBackgroundSync(mx);
|
||||||
|
|
||||||
|
const onVisibility = () => setAppForegroundState(!document.hidden);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
setAppForegroundState(!document.hidden);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
stopBackgroundSync();
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the Paarrot API for Electron integration
|
* Initializes the Paarrot API for Electron integration
|
||||||
* Registers the navigate function and sets up IPC handlers
|
* Registers the navigate function and sets up IPC handlers
|
||||||
@@ -475,6 +505,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<FaviconUpdater />
|
<FaviconUpdater />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
|
<BackgroundSyncSetup />
|
||||||
<PaarrotAPIInitializer />
|
<PaarrotAPIInitializer />
|
||||||
<TaskbarFlashStopper />
|
<TaskbarFlashStopper />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
91
overlay/src/app/utils/backgroundSync.ts
Normal file
91
overlay/src/app/utils/backgroundSync.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
|
import type { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
interface MatrixBackgroundSyncPlugin {
|
||||||
|
/** Start the background sync service with the given Matrix credentials. */
|
||||||
|
start(options: {
|
||||||
|
homeserverUrl: string;
|
||||||
|
accessToken: string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
/** Stop the background sync service and clear persisted credentials. */
|
||||||
|
stop(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Notify the service whether the app UI is visible.
|
||||||
|
* When foreground is true, the service suppresses native notifications
|
||||||
|
* because the JS layer handles them via LocalNotifications.
|
||||||
|
*/
|
||||||
|
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
||||||
|
/** Returns whether the sync service is currently running. */
|
||||||
|
getStatus(): Promise<{ running: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||||
|
|
||||||
|
/** Returns true when the current platform is Android Capacitor. */
|
||||||
|
export const isBackgroundSyncSupported = (): boolean =>
|
||||||
|
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the native Matrix background sync service.
|
||||||
|
* Reads credentials directly from the active MatrixClient.
|
||||||
|
* Safe to call on every login — the service is idempotent.
|
||||||
|
* @param mx Authenticated Matrix client
|
||||||
|
*/
|
||||||
|
export const startBackgroundSync = async (mx: MatrixClient): Promise<void> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
const homeserverUrl = mx.getHomeserverUrl();
|
||||||
|
const accessToken = mx.getAccessToken();
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
const deviceId = mx.getDeviceId();
|
||||||
|
|
||||||
|
if (!homeserverUrl || !accessToken || !userId) {
|
||||||
|
console.warn('[BackgroundSync] Missing credentials, not starting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.start({
|
||||||
|
homeserverUrl,
|
||||||
|
accessToken,
|
||||||
|
userId,
|
||||||
|
deviceId: deviceId ?? '',
|
||||||
|
});
|
||||||
|
console.log('[BackgroundSync] Service started');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] Failed to start:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the native Matrix background sync service.
|
||||||
|
* Call on logout so the service stops and credentials are wiped.
|
||||||
|
*/
|
||||||
|
export const stopBackgroundSync = async (): Promise<void> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.stop();
|
||||||
|
console.log('[BackgroundSync] Service stopped');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] Failed to stop:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the native service whether the app UI is in the foreground.
|
||||||
|
* Call when document visibility changes so the service can suppress
|
||||||
|
* duplicate notifications while the JS layer is active.
|
||||||
|
* @param foreground true if the WebView UI is currently visible
|
||||||
|
*/
|
||||||
|
export const setAppForegroundState = async (foreground: boolean): Promise<void> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.setAppForeground({ foreground });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] setAppForeground failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.78",
|
"version": "4.11.83",
|
||||||
"description": "Paarrot - A Matrix client based on Cinny",
|
"description": "Paarrot - A Matrix client based on Cinny",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
@@ -120,7 +120,23 @@ if (!existsSync(androidJunction)) {
|
|||||||
console.log(` android/ link already exists, skipping`);
|
console.log(` android/ link already exists, skipping`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Write android/local.properties with the current machine's Android SDK path
|
// 8. Sync version from root package.json into android/app/build.gradle
|
||||||
|
console.log('[apply-overlay] Syncing version into build.gradle...');
|
||||||
|
const rootPkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
||||||
|
const version = rootPkg.version ?? '1.0.0';
|
||||||
|
const parts = version.split('.').map(Number);
|
||||||
|
// versionCode: major*10000 + minor*100 + patch (e.g. 4.11.78 -> 41178)
|
||||||
|
const versionCode = (parts[0] ?? 0) * 10000 + (parts[1] ?? 0) * 100 + (parts[2] ?? 0);
|
||||||
|
|
||||||
|
const buildGradlePath = join(androidTarget, 'app', 'build.gradle');
|
||||||
|
let buildGradle = readFileSync(buildGradlePath, 'utf8');
|
||||||
|
buildGradle = buildGradle
|
||||||
|
.replace(/versionCode\s+\d+/, `versionCode ${versionCode}`)
|
||||||
|
.replace(/versionName\s+"[^"]+"/, `versionName "${version}"`);
|
||||||
|
writeFileSync(buildGradlePath, buildGradle, 'utf8');
|
||||||
|
console.log(` versionName "${version}", versionCode ${versionCode}`);
|
||||||
|
|
||||||
|
// 9. Write android/local.properties with the current machine's Android SDK path
|
||||||
const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
||||||
if (androidHome) {
|
if (androidHome) {
|
||||||
console.log('[apply-overlay] Writing android/local.properties...');
|
console.log('[apply-overlay] Writing android/local.properties...');
|
||||||
|
|||||||
Reference in New Issue
Block a user