diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..ff594a9 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,54 @@ +apply plugin: 'com.android.application' + +android { + namespace = "com.paarrot.app" + compileSdk = rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.paarrot.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle new file mode 100644 index 0000000..a064f6f --- /dev/null +++ b/android/app/capacitor.build.gradle @@ -0,0 +1,19 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':capacitor-local-notifications') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..36e642c --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/paarrot/app/MainActivity.java b/android/app/src/main/java/com/paarrot/app/MainActivity.java new file mode 100644 index 0000000..4ed2af6 --- /dev/null +++ b/android/app/src/main/java/com/paarrot/app/MainActivity.java @@ -0,0 +1,5 @@ +package com.paarrot.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/android/app/src/main/res/drawable-land-hdpi/splash.png b/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..e31573b Binary files /dev/null and b/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-mdpi/splash.png b/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xhdpi/splash.png b/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..8077255 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..14c6c8f Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..244ca25 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-hdpi/splash.png b/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..74faaa5 Binary files /dev/null and b/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-mdpi/splash.png b/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..e944f4a Binary files /dev/null and b/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xhdpi/splash.png b/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..564a82f Binary files /dev/null and b/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..bfabe68 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..6929071 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_stat_paarrot.xml b/android/app/src/main/res/drawable/ic_stat_paarrot.xml new file mode 100644 index 0000000..8ea915e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_paarrot.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/splash.png b/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/android/app/src/main/res/drawable/splash.png differ diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..3b6a8a2 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3b6a8a2 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..3b6a8a2 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..ccf1373 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ccf1373 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..ccf1373 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..daa945b Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..daa945b Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..daa945b Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..1075c84 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1075c84 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1075c84 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..2c4b829 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2c4b829 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..2c4b829 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d828ff7 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Paarrot + Paarrot + com.paarrot.app + com.paarrot.app + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..be874e5 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..f8f0e43 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.google.gms:google-services:4.4.4' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle new file mode 100644 index 0000000..127f1ac --- /dev/null +++ b/android/capacitor.settings.gradle @@ -0,0 +1,6 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') + +include ':capacitor-local-notifications' +project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android') diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7705927 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/android/variables.gradle b/android/variables.gradle new file mode 100644 index 0000000..ee4ba41 --- /dev/null +++ b/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.11.0' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.17.0' + androidxFragmentVersion = '1.8.9' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' +} \ No newline at end of file diff --git a/capacitor.config.json b/capacitor.config.json new file mode 100644 index 0000000..ce171fa --- /dev/null +++ b/capacitor.config.json @@ -0,0 +1,12 @@ +{ + "appId": "com.paarrot.app", + "appName": "Paarrot", + "webDir": "cinny/dist", + "bundledWebRuntime": false, + "plugins": { + "LocalNotifications": { + "smallIcon": "ic_stat_paarrot", + "iconColor": "#FF8A00" + } + } +} diff --git a/overlay/package-additions.json b/overlay/package-additions.json new file mode 100644 index 0000000..c522c0c --- /dev/null +++ b/overlay/package-additions.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "@capacitor/android": "8.3.0", + "@capacitor/cli": "8.3.0", + "@capacitor/core": "8.3.0", + "@capacitor/local-notifications": "8.0.2" + } +} diff --git a/overlay/src/app/features/settings/notifications/SystemNotification.tsx b/overlay/src/app/features/settings/notifications/SystemNotification.tsx new file mode 100644 index 0000000..f8f5764 --- /dev/null +++ b/overlay/src/app/features/settings/notifications/SystemNotification.tsx @@ -0,0 +1,160 @@ +import React, { useCallback } from 'react'; +import { Box, Text, Switch, Button, color, Spinner } from 'folds'; +import { IPusherRequest } from 'matrix-js-sdk'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { getNotificationState, usePermissionState } from '../../../hooks/usePermission'; +import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri'; + +function EmailNotification() { + const mx = useMatrixClient(); + const [result, refreshResult] = useEmailNotifications(); + + const [setState, setEnable] = useAsyncCallback( + useCallback( + async (email: string, enable: boolean) => { + if (enable) { + await mx.setPusher({ + kind: 'email', + app_id: 'm.email', + pushkey: email, + app_display_name: 'Email Notifications', + device_display_name: email, + lang: 'en', + data: { + brand: 'Paarrot', + }, + append: true, + }); + return; + } + await mx.setPusher({ + pushkey: email, + app_id: 'm.email', + kind: null, + } as unknown as IPusherRequest); + }, + [mx] + ) + ); + + const handleChange = (value: boolean) => { + if (result && result.email) { + setEnable(result.email, value).then(() => { + refreshResult(); + }); + } + }; + + return ( + + {result && !result.email && ( + + Your account does not have any email attached. + + )} + {result && result.email && <>Send notification to your email. {`("${result.email}")`}} + {result === null && ( + + Unexpected Error! + + )} + {result === undefined && 'Send notification to your email.'} + + } + after={ + <> + {setState.status !== AsyncStatus.Loading && + typeof result === 'object' && + result?.email && } + {(setState.status === AsyncStatus.Loading || result === undefined) && ( + + )} + + } + /> + ); +} + +export function SystemNotification() { + const notifPermission = usePermissionState('notifications', getNotificationState()); + const capacitorNative = isCapacitorNative(); + const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); + const [isNotificationSounds, setIsNotificationSounds] = useSetting( + settingsAtom, + 'isNotificationSounds' + ); + + const requestNotificationPermission = async () => { + await requestSystemNotificationPermission(); + }; + + return ( + + System + + + {'Notification' in window + ? 'Notification permission is blocked. Please allow notification permission from browser address bar.' + : 'Notifications are not supported by the system.'} + + ) : ( + Show desktop notifications when message arrive. + ) + } + after={ + notifPermission === 'prompt' ? ( + + ) : ( + + ) + } + /> + + + } + /> + + + + + + ); +} diff --git a/overlay/src/app/hooks/useAuthenticatedMediaUrl.ts b/overlay/src/app/hooks/useAuthenticatedMediaUrl.ts new file mode 100644 index 0000000..0570d00 --- /dev/null +++ b/overlay/src/app/hooks/useAuthenticatedMediaUrl.ts @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useMatrixClient } from './useMatrixClient'; +import { getCurrentAccessToken } from '../utils/auth'; + +/** + * Fetches media with authentication and returns a blob URL. + * This is needed because service workers may not work reliably in Tauri/WebKit environments, + * and cross-origin image requests cannot include Authorization headers. + */ +export const useAuthenticatedMediaUrl = ( + src: string | undefined, + useAuthentication: boolean +): string | undefined => { + const mx = useMatrixClient(); + const [blobUrl, setBlobUrl] = useState(undefined); + + useEffect(() => { + if (!src) { + setBlobUrl(undefined); + return; + } + + // If not using authentication, just return the original URL + if (!useAuthentication) { + setBlobUrl(src); + return; + } + + // Check if this is an authenticated media URL + const isAuthenticatedMediaUrl = + src.includes('/_matrix/client/v1/media/download') || + src.includes('/_matrix/client/v1/media/thumbnail') || + (src.includes('/_matrix/media/') && + (src.includes('/download/') || src.includes('/thumbnail/'))); + + if (!isAuthenticatedMediaUrl) { + setBlobUrl(src); + return; + } + + let cancelled = false; + let objectUrl: string | undefined; + + const fetchMedia = async () => { + try { + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); + let response = await fetch(src, { + method: 'GET', + headers: accessToken + ? { Authorization: `Bearer ${accessToken}` } + : undefined, + }); + + // If we got a 401 and we tried with auth, fallback to unauthenticated request + if (!response.ok && response.status === 401 && accessToken) { + console.warn('[useAuthenticatedMediaUrl] Auth failed (401), attempting unauthenticated fallback for:', src); + response = await fetch(src, { method: 'GET' }); + } + + if (!response.ok) { + console.warn(`Failed to fetch authenticated media: ${response.status}`); + // Fall back to original URL in case server doesn't require auth + if (!cancelled) setBlobUrl(src); + return; + } + + const blob = await response.blob(); + if (!cancelled) { + objectUrl = URL.createObjectURL(blob); + setBlobUrl(objectUrl); + } + } catch (error) { + console.warn('Error fetching authenticated media:', error); + // Fall back to original URL + if (!cancelled) setBlobUrl(src); + } + }; + + fetchMedia(); + + return () => { + cancelled = true; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [src, useAuthentication, mx]); + + return blobUrl; +}; + +/** + * Creates an authenticated fetch function for media URLs. + * Useful for components that need to load multiple images. + */ +export const useAuthenticatedMediaFetch = () => { + const mx = useMatrixClient(); + + return useCallback( + async (src: string): Promise => { + const isAuthenticatedMediaUrl = + src.includes('/_matrix/client/v1/media/download') || + src.includes('/_matrix/client/v1/media/thumbnail') || + (src.includes('/_matrix/media/') && + (src.includes('/download/') || src.includes('/thumbnail/'))); + + if (!isAuthenticatedMediaUrl) { + return src; + } + + try { + // Always use current session's token to avoid stale tokens during account switches + const accessToken = getCurrentAccessToken(); + let response = await fetch(src, { + method: 'GET', + headers: accessToken + ? { Authorization: `Bearer ${accessToken}` } + : undefined, + }); + + // If we got a 401 and we tried with auth, fallback to unauthenticated request + if (!response.ok && response.status === 401 && accessToken) { + console.warn('[useAuthenticatedMediaFetch] Auth failed (401), attempting unauthenticated fallback'); + response = await fetch(src, { method: 'GET' }); + } + + if (!response.ok) { + console.warn(`Failed to fetch authenticated media: ${response.status}`); + return src; + } + + const blob = await response.blob(); + return URL.createObjectURL(blob); + } catch (error) { + console.warn('Error fetching authenticated media:', error); + return src; + } + }, + [mx] + ); +}; diff --git a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx new file mode 100644 index 0000000..5f1582b --- /dev/null +++ b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx @@ -0,0 +1,483 @@ +import { useAtomValue } from 'jotai'; +import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; +import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; +import LogoSVG from '../../../../public/res/svg/paarrot.svg'; +import LogoUnreadSVG from '../../../../public/res/svg/paarrot-unread.svg'; +import LogoHighlightSVG from '../../../../public/res/svg/paarrot-highlight.svg'; +import { notificationPermission, setFavicon } from '../../utils/dom'; +import { useSetting } from '../../state/hooks/settings'; +import { EmojiStyle, settingsAtom } from '../../state/settings'; +import { allInvitesAtom } from '../../state/room-list/inviteList'; +import { usePreviousValue } from '../../hooks/usePreviousValue'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath, getInboxInvitesPath } from '../pathUtils'; +import { + getMemberDisplayName, + getNotificationType, + getUnreadInfo, + isNotificationEvent, + getOrphanParents, + guessPerfectParent, +} from '../../utils/room'; +import { NotificationType, UnreadInfo } from '../../../types/matrix/room'; +import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId } from '../../utils/matrix'; +import { mDirectAtom } from '../../state/mDirectList'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; +import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; +import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { + isTauri, + isElectron, + isCapacitorNative, + sendNotification, + setupNotificationTapListener, +} from '../../utils/tauri'; +import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api'; + +/** + * Applies the selected emoji style font to the document. + * - System: Uses the native OS emoji font + * - Apple: Uses Apple Color Emoji (bundled font) + * - Twemoji: Uses Twitter's Twemoji font + */ +function EmojiStyleFeature() { + const [emojiStyle] = useSetting(settingsAtom, 'emojiStyle'); + + switch (emojiStyle) { + case EmojiStyle.Apple: + document.documentElement.style.setProperty('--font-emoji', 'AppleColorEmoji'); + break; + case EmojiStyle.Twemoji: + document.documentElement.style.setProperty('--font-emoji', 'Twemoji'); + break; + case EmojiStyle.System: + default: + document.documentElement.style.setProperty('--font-emoji', 'SystemEmoji'); + break; + } + + return null; +} + +function PageZoomFeature() { + const [pageZoom] = useSetting(settingsAtom, 'pageZoom'); + + if (pageZoom === 100) { + document.documentElement.style.removeProperty('font-size'); + } else { + document.documentElement.style.setProperty('font-size', `calc(1em * ${pageZoom / 100})`); + } + + return null; +} + +function FaviconUpdater() { + const roomToUnread = useAtomValue(roomToUnreadAtom); + + useEffect(() => { + let notification = false; + let highlight = false; + roomToUnread.forEach((unread) => { + if (unread.total > 0) { + notification = true; + } + if (unread.highlight > 0) { + highlight = true; + } + }); + + if (notification) { + setFavicon(highlight ? LogoHighlightSVG : LogoUnreadSVG); + } else { + setFavicon(LogoSVG); + } + }, [roomToUnread]); + + return null; +} + +function InviteNotifications() { + const invites = useAtomValue(allInvitesAtom); + const perviousInviteLen = usePreviousValue(invites.length, 0); + const mx = useMatrixClient(); + + const navigate = useNavigate(); + const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); + const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); + + const notify = useCallback( + (count: number) => { + const body = `You have ${count} new invitation request.`; + const invitesPath = getInboxInvitesPath(); + + // Flash taskbar icon for desktop notifications (only visible when window is not focused) + if (isElectron() && (window as any).electron?.window?.flashFrame) { + (window as any).electron.window.flashFrame(true) + .then((result: { success: boolean }) => { + console.log('[InviteNotifications] flashFrame result:', result); + }) + .catch((err: Error) => { + console.error('[InviteNotifications] flashFrame error:', err); + }); + } + + if ((isTauri() && !isElectron()) || isCapacitorNative()) { + sendNotification({ + title: 'Invitation', + body, + path: invitesPath, + onClick: () => { + if (!window.closed) navigate(invitesPath); + }, + }); + } else { + const noti = new window.Notification('Invitation', { + icon: LogoSVG, + badge: LogoSVG, + body, + silent: true, + }); + + noti.onclick = () => { + window.focus(); + if (!window.closed) navigate(invitesPath); + noti.close(); + // Stop flashing when user clicks notification + if ((window as any).electron?.window?.flashFrame) { + (window as any).electron.window.flashFrame(false); + } + }; + } + }, + [navigate] + ); + + const playSound = useCallback(() => { + console.log('[InviteNotifications] playSound called, isElectron:', isElectron()); + if (isElectron() && (window as any).electron?.audio?.playNotificationSound) { + console.log('[InviteNotifications] Using Electron audio API'); + (window as any).electron.audio.playNotificationSound('invite') + .then((result: any) => console.log('[InviteNotifications] Sound result:', result)) + .catch((err: any) => console.error('[InviteNotifications] Sound error:', err)); + } else { + console.log('[InviteNotifications] Using HTML5 Audio fallback'); + new Audio('./sound/invite.ogg').play().catch((err) => { + console.error('[Audio] Failed to play invite sound:', err); + }); + } + }, []); + + useEffect(() => { + if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') { + if (showNotifications && notificationPermission('granted')) { + notify(invites.length - perviousInviteLen); + } + + if (notificationSound) { + playSound(); + } + } + }, [mx, invites, perviousInviteLen, showNotifications, notificationSound, notify, playSound]); + + return null; +} + +function MessageNotifications() { + const notifRef = useRef(); + const unreadCacheRef = useRef>(new Map()); + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); + const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); + const mDirects = useAtomValue(mDirectAtom); + const roomToParents = useAtomValue(roomToParentsAtom); + + const navigate = useNavigate(); + const notificationSelected = useInboxNotificationsSelected(); + const selectedRoomId = useSelectedRoom(); + + // Set up notification tap listener for mobile + useEffect(() => { + setupNotificationTapListener((path) => { + navigate(path); + }); + }, [navigate]); + + const notify = useCallback( + ({ + roomName, + roomAvatar, + username, + messageBody, + roomId, + eventId, + isDm, + }: { + roomName: string; + roomAvatar?: string; + username: string; + messageBody?: string; + roomId: string; + eventId: string; + isDm: boolean; + }) => { + const notificationTitle = username; + const notificationBody = messageBody || 'New message'; + + /** Replicates TitleBar click navigation logic */ + const navigateToRoom = () => { + if (!mx) return; + try { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); + if (mDirects.has(roomId)) { + navigate(getDirectRoomPath(roomIdOrAlias, eventId)); + return; + } + const orphanParents = getOrphanParents(roomToParents, roomId); + if (orphanParents.length > 0) { + const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0]; + const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); + navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId)); + return; + } + navigate(getHomeRoomPath(roomIdOrAlias, eventId)); + } catch (err) { + console.error('[Notifications] Navigate error:', err); + } + }; + + // Flash taskbar icon for desktop notifications (only visible when window is not focused) + console.log('[Notifications] Attempting flashFrame'); + if (isElectron() && (window as any).electron?.window?.flashFrame) { + (window as any).electron.window.flashFrame(true) + .then((result: { success: boolean }) => { + console.log('[Notifications] flashFrame result:', result); + }) + .catch((err: Error) => { + console.error('[Notifications] flashFrame error:', err); + }); + } else { + console.warn('[Notifications] flashFrame not available, isElectron:', isElectron()); + } + + if ((isTauri() && !isElectron()) || isCapacitorNative()) { + const roomPath = isDm + ? getDirectRoomPath(roomId, eventId) + : getHomeRoomPath(roomId, eventId); + sendNotification({ + title: notificationTitle, + body: notificationBody, + path: roomPath, + onClick: () => { + if (!window.closed) navigate(roomPath); + }, + }); + } else { + // Use renderer window.Notification — works in both Electron and browser. + // Main-process Notification click events are unreliable on Windows without + // full COM/AUMID registration, so we keep everything in the renderer. + const iconUrl = roomAvatar ?? '/res/android/android-chrome-192x192.png'; + + const noti = new window.Notification(notificationTitle, { + icon: iconUrl, + body: notificationBody, + silent: true, + }); + + noti.onclick = () => { + // Tell main process to bring window to front + (window as any).electron?.window?.focus(); + if (!window.closed) navigateToRoom(); + noti.close(); + notifRef.current = undefined; + // Stop flashing when user clicks notification + if ((window as any).electron?.window?.flashFrame) { + (window as any).electron.window.flashFrame(false); + } + }; + + notifRef.current?.close(); + notifRef.current = noti; + } + }, + [mx, navigate, mDirects, roomToParents] + ); + + const playSound = useCallback(() => { + console.log('[MessageNotifications] playSound called, isElectron:', isElectron()); + if (isElectron() && (window as any).electron?.audio?.playNotificationSound) { + console.log('[MessageNotifications] Using Electron audio API'); + (window as any).electron.audio.playNotificationSound('message') + .then((result: any) => console.log('[MessageNotifications] Sound result:', result)) + .catch((err: any) => console.error('[MessageNotifications] Sound error:', err)); + } else { + console.log('[MessageNotifications] Using HTML5 Audio fallback'); + new Audio('./sound/notification.ogg').play().catch((err) => { + console.error('[Audio] Failed to play notification sound:', err); + }); + } + }, []); + + useEffect(() => { + const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = ( + mEvent, + room, + toStartOfTimeline, + removed, + data + ) => { + if (mx.getSyncState() !== 'SYNCING') return; + if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return; + if ( + !room || + !data.liveEvent || + room.isSpaceRoom() || + !isNotificationEvent(mEvent) || + getNotificationType(mx, room.roomId) === NotificationType.Mute + ) { + return; + } + + const sender = mEvent.getSender(); + const eventId = mEvent.getId(); + if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return; + const unreadInfo = getUnreadInfo(room); + const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId); + unreadCacheRef.current.set(room.roomId, unreadInfo); + + if (unreadInfo.total === 0) return; + if ( + cachedUnreadInfo && + unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo)) + ) { + return; + } + + if ( + showNotifications && + ((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted')) + ) { + const avatarMxc = + room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); + const content = mEvent.getContent(); + + let messageBody: string | undefined; + if (mEvent.getType() === 'm.reaction') { + // For reactions, show "reacted with {emoji}" + const reactionKey = content['m.relates_to']?.key; + if (reactionKey) { + messageBody = `reacted with ${reactionKey}`; + } else { + messageBody = 'reacted to a message'; + } + } else { + messageBody = typeof content.body === 'string' ? content.body : undefined; + } + + const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom(); + notify({ + roomName: room.name ?? 'Unknown', + roomAvatar: avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined + : undefined, + username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender, + messageBody, + roomId: room.roomId, + eventId, + isDm, + }); + } + + if (notificationSound) { + playSound(); + } + }; + mx.on(RoomEvent.Timeline, handleTimelineEvent); + return () => { + mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); + }; + }, [ + mx, + notificationSound, + notificationSelected, + showNotifications, + playSound, + notify, + selectedRoomId, + useAuthentication, + ]); + + return null; +} + +/** + * Initializes the Paarrot API for Electron integration + * Registers the navigate function and sets up IPC handlers + */ +function PaarrotAPIInitializer() { + const navigate = useNavigate(); + const mx = useMatrixClient(); + + useEffect(() => { + // Register navigate function for Paarrot API + setPaarrotNavigate(navigate); + + // Initialize Paarrot API handlers + initPaarrotAPI(mx); + + console.log('Paarrot API: Initialized with navigate function'); + }, [navigate, mx]); + + return null; +} + +/** + * Stops taskbar icon flashing when window gains focus + */ +function TaskbarFlashStopper() { + useEffect(() => { + // Log what's available on mount + console.log('[TaskbarFlashStopper] window.electron:', (window as any).electron); + console.log('[TaskbarFlashStopper] Available APIs:', { + hasWindow: !!(window as any).electron?.window, + hasFlashFrame: !!(window as any).electron?.window?.flashFrame, + hasAudio: !!(window as any).electron?.audio, + hasPlaySound: !!(window as any).electron?.audio?.playNotificationSound, + }); + + const handleFocus = () => { + if ((window as any).electron?.window?.flashFrame) { + (window as any).electron.window.flashFrame(false); + } + }; + + window.addEventListener('focus', handleFocus); + return () => { + window.removeEventListener('focus', handleFocus); + }; + }, []); + + return null; +} + +type ClientNonUIFeaturesProps = { + children: ReactNode; +}; + +export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + return ( + <> + + + + + + + + {children} + + ); +} diff --git a/overlay/src/app/utils/matrix.ts b/overlay/src/app/utils/matrix.ts new file mode 100644 index 0000000..3de6911 --- /dev/null +++ b/overlay/src/app/utils/matrix.ts @@ -0,0 +1,421 @@ +import { + EncryptedAttachmentInfo, + decryptAttachment, + encryptAttachment, +} from 'browser-encrypt-attachment'; +import { + EventTimeline, + MatrixClient, + MatrixError, + MatrixEvent, + Room, + RoomMember, + UploadProgress, + UploadResponse, +} from 'matrix-js-sdk'; +import to from 'await-to-js'; +import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; +import { AccountDataEvent } from '../../types/matrix/accountData'; +import { getStateEvent } from './room'; +import { Membership, StateEvent } from '../../types/matrix/room'; + +const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; + +export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName); + +const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])([^\s:]+):(\S+)$/); + +const validMxId = (id: string): boolean => !!matchMxId(id); + +export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3]; + +export const getMxIdLocalPart = (userId: string): string | undefined => matchMxId(userId)?.[2]; + +export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@'); + +export const isRoomId = (id: string): boolean => id.startsWith('!'); + +export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#'); + +export const getCanonicalAliasRoomId = (mx: MatrixClient, alias: string): string | undefined => + mx + .getRooms() + ?.find( + (room) => + room.getCanonicalAlias() === alias && + getStateEvent(room, StateEvent.RoomTombstone) === undefined + )?.roomId; + +export const getCanonicalAliasOrRoomId = (mx: MatrixClient, roomId: string): string => { + const room = mx.getRoom(roomId); + if (!room) return roomId; + if (getStateEvent(room, StateEvent.RoomTombstone) !== undefined) return roomId; + const alias = room.getCanonicalAlias(); + if (alias && getCanonicalAliasRoomId(mx, alias) === roomId) { + return alias; + } + return roomId; +}; + +export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => { + const info: IImageInfo = {}; + info.w = img.width; + info.h = img.height; + info.mimetype = fileOrBlob.type; + info.size = fileOrBlob.size; + return info; +}; + +export const getVideoInfo = (video: HTMLVideoElement, fileOrBlob: File | Blob): IVideoInfo => { + const info: IVideoInfo = {}; + info.duration = Number.isNaN(video.duration) ? undefined : Math.floor(video.duration * 1000); + info.w = video.videoWidth; + info.h = video.videoHeight; + info.mimetype = fileOrBlob.type; + info.size = fileOrBlob.size; + return info; +}; + +export const getThumbnailContent = (thumbnailInfo: { + thumbnail: File | Blob; + encInfo: EncryptedAttachmentInfo | undefined; + mxc: string; + width: number; + height: number; +}): IThumbnailContent => { + const { thumbnail, encInfo, mxc, width, height } = thumbnailInfo; + + const content: IThumbnailContent = { + thumbnail_info: { + mimetype: thumbnail.type, + size: thumbnail.size, + w: width, + h: height, + }, + }; + if (encInfo) { + content.thumbnail_file = { + ...encInfo, + url: mxc, + }; + } else { + content.thumbnail_url = mxc; + } + return content; +}; + +export const encryptFile = async ( + file: File | Blob +): Promise<{ + encInfo: EncryptedAttachmentInfo; + file: File; + originalFile: File | Blob; +}> => { + const dataBuffer = await file.arrayBuffer(); + const encryptedAttachment = await encryptAttachment(dataBuffer); + const encFile = new File([encryptedAttachment.data], file.name, { + type: file.type, + }); + return { + encInfo: encryptedAttachment.info, + file: encFile, + originalFile: file, + }; +}; + +export const decryptFile = async ( + dataBuffer: ArrayBuffer, + type: string, + encInfo: EncryptedAttachmentInfo +): Promise => { + const dataArray = await decryptAttachment(dataBuffer, encInfo); + const blob = new Blob([dataArray], { type }); + return blob; +}; + +export type TUploadContent = File | Blob; + +export type ContentUploadOptions = { + name?: string; + fileType?: string; + hideFilename?: boolean; + onPromise?: (promise: Promise) => void; + onProgress?: (progress: UploadProgress) => void; + onSuccess: (mxc: string) => void; + onError: (error: MatrixError) => void; +}; + +export const uploadContent = async ( + mx: MatrixClient, + file: TUploadContent, + options: ContentUploadOptions +) => { + const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; + + const uploadPromise = mx.uploadContent(file, { + name, + type: fileType, + includeFilename: !hideFilename, + progressHandler: onProgress, + }); + onPromise?.(uploadPromise); + try { + const data = await uploadPromise; + const mxc = data.content_uri; + if (mxc) onSuccess(mxc); + else onError(new MatrixError(data)); + } catch (e: any) { + const error = typeof e?.message === 'string' ? e.message : undefined; + const errcode = typeof e?.name === 'string' ? e.message : undefined; + onError(new MatrixError({ error, errcode })); + } +}; + +export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs(); + +export const factoryEventSentBy = (senderId: string) => (ev: MatrixEvent) => + ev.getSender() === senderId; + +export const eventWithShortcode = (ev: MatrixEvent) => + typeof ev.getContent().shortcode === 'string'; + +export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined => { + const dmLikeRooms = mx + .getRooms() + .filter( + (room) => + room.getMyMembership() === Membership.Join && + room.hasEncryptionStateEvent() && + room.getMembers().length <= 2 + ); + + return dmLikeRooms.find((room) => room.getMember(userId)); +}; + +export const guessDmRoomUserId = (room: Room, myUserId: string): string => { + const getOldestMember = (members: RoomMember[]): RoomMember | undefined => { + let oldestMemberTs: number | undefined; + let oldestMember: RoomMember | undefined; + + const pickOldestMember = (member: RoomMember) => { + if (member.userId === myUserId) return; + + if ( + oldestMemberTs === undefined || + (member.events.member && member.events.member.getTs() < oldestMemberTs) + ) { + oldestMember = member; + oldestMemberTs = member.events.member?.getTs(); + } + }; + + members.forEach(pickOldestMember); + + return oldestMember; + }; + + // Pick the joined user who's been here longest (and isn't us), + const member = getOldestMember(room.getJoinedMembers()); + if (member) return member.userId; + + // if there are no joined members other than us, use the oldest member + const member1 = getOldestMember( + room.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getMembers() ?? [] + ); + return member1?.userId ?? myUserId; +}; + +export const addRoomIdToMDirect = async ( + mx: MatrixClient, + roomId: string, + userId: string +): Promise => { + const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); + let userIdToRoomIds: Record = {}; + + if (typeof mDirectsEvent !== 'undefined') + userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); + + // remove it from the lists of any others users + // (it can only be a DM room for one person) + Object.keys(userIdToRoomIds).forEach((targetUserId) => { + const roomIds = userIdToRoomIds[targetUserId]; + + if (targetUserId !== userId) { + const indexOfRoomId = roomIds.indexOf(roomId); + if (indexOfRoomId > -1) { + roomIds.splice(indexOfRoomId, 1); + } + } + }); + + const roomIds = userIdToRoomIds[userId] || []; + if (roomIds.indexOf(roomId) === -1) { + roomIds.push(roomId); + } + userIdToRoomIds[userId] = roomIds; + + await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); +}; + +export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise => { + const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); + let userIdToRoomIds: Record = {}; + + if (typeof mDirectsEvent !== 'undefined') + userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); + + Object.keys(userIdToRoomIds).forEach((targetUserId) => { + const roomIds = userIdToRoomIds[targetUserId]; + const indexOfRoomId = roomIds.indexOf(roomId); + if (indexOfRoomId > -1) { + roomIds.splice(indexOfRoomId, 1); + } + }); + + await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); +}; + +export const mxcUrlToHttp = ( + mx: MatrixClient, + mxcUrl: string, + useAuthentication?: boolean, + width?: number, + height?: number, + resizeMethod?: string, + allowDirectLinks?: boolean, + allowRedirects?: boolean +): string | null => + mx.mxcUrlToHttp( + mxcUrl, + width, + height, + resizeMethod, + allowDirectLinks, + allowRedirects, + useAuthentication + ); + +/** + * Check if a URL is an authenticated media endpoint. + */ +export const isAuthenticatedMediaUrl = (url: string): boolean => + url.includes('/_matrix/client/v1/media/download') || + url.includes('/_matrix/client/v1/media/thumbnail') || + url.includes('/_matrix/media/') && + (url.includes('/download/') || url.includes('/thumbnail/')); + +/** + * Downloads media with optional authentication. + * For authenticated media URLs, the access token is required. + * Falls back to unauthenticated request if authenticated request fails with 401. + * Returns an empty blob with error type if all attempts fail, to prevent cascading failures. + * + * @param src - The media URL to download + * @param accessToken - Optional access token (if not provided, will use current session's token) + */ +export const downloadMedia = async (src: string, accessToken?: string | null): Promise => { + // Import here to avoid circular dependencies + const { getCurrentAccessToken } = await import('./auth'); + + // Use provided token, or fall back to current session's token + const token = accessToken ?? getCurrentAccessToken(); + const needsAuth = isAuthenticatedMediaUrl(src); + const headers: HeadersInit = {}; + + if (needsAuth && token) { + headers.Authorization = `Bearer ${token}`; + } + + try { + let res = await fetch(src, { method: 'GET', headers }); + + // If we got a 401 and we tried with auth, fallback to unauthenticated request + if (!res.ok && res.status === 401 && needsAuth && token) { + console.warn('[downloadMedia] Auth failed (401), attempting unauthenticated fallback for:', src); + res = await fetch(src, { method: 'GET' }); + } + + if (!res.ok) { + console.warn(`[downloadMedia] Failed to download media (${res.status}) from:`, src); + throw new Error(`Failed to download media: ${res.status}`); + } + const blob = await res.blob(); + return blob; + } catch (error) { + // Log but re-throw so callers can handle appropriately + console.warn('[downloadMedia] Error downloading media:', error); + throw error; + } +}; + +export const downloadEncryptedMedia = async ( + src: string, + decryptContent: (buf: ArrayBuffer) => Promise, + accessToken?: string | null +): Promise => { + const encryptedContent = await downloadMedia(src, accessToken); + const decryptedContent = await decryptContent(await encryptedContent.arrayBuffer()); + + return decryptedContent; +}; + +export const rateLimitedActions = async ( + data: T[], + callback: (item: T, index: number) => Promise, + maxRetryCount?: number +) => { + let retryCount = 0; + + let actionInterval = 0; + + const sleepForMs = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + + const performAction = async (dataItem: T, index: number) => { + const [err] = await to(callback(dataItem, index)); + + if (err?.httpStatus === 429) { + if (retryCount === maxRetryCount) { + return; + } + + const waitMS = err.getRetryAfterMs() ?? 3000; + actionInterval = waitMS * 1.5; + await sleepForMs(waitMS); + retryCount += 1; + + await performAction(dataItem, index); + } + }; + + for (let i = 0; i < data.length; i += 1) { + const dataItem = data[i]; + retryCount = 0; + // eslint-disable-next-line no-await-in-loop + await performAction(dataItem, i); + if (actionInterval > 0) { + // eslint-disable-next-line no-await-in-loop + await sleepForMs(actionInterval); + } + } +}; + +export const knockSupported = (version: string): boolean => { + const unsupportedVersion = ['1', '2', '3', '4', '5', '6']; + return !unsupportedVersion.includes(version); +}; +export const restrictedSupported = (version: string): boolean => { + const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7']; + return !unsupportedVersion.includes(version); +}; +export const knockRestrictedSupported = (version: string): boolean => { + const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; + return !unsupportedVersion.includes(version); +}; +export const creatorsSupported = (version: string): boolean => { + const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; + return !unsupportedVersion.includes(version); +}; diff --git a/overlay/src/app/utils/tauri.ts b/overlay/src/app/utils/tauri.ts new file mode 100644 index 0000000..c8f3545 --- /dev/null +++ b/overlay/src/app/utils/tauri.ts @@ -0,0 +1,625 @@ +/** + * Open an external URL using Tauri opener plugin on desktop/mobile, or window.open in browser + * @param url The URL to open + */ +export const openExternalUrl = async (url: string): Promise => { + console.log('[openExternalUrl] called with:', url); + + // Use Electron's shell.openExternal if in Electron + if (isElectron()) { + try { + const electron = (window as any).electron; + if (electron?.shell?.openExternal) { + await electron.shell.openExternal(url); + console.log('[openExternalUrl] Electron shell.openExternal succeeded'); + return; + } + } catch (err) { + console.warn('[openExternalUrl] Electron shell.openExternal failed:', err); + } + } + + // Use Tauri for actual Tauri builds + if (isTauri() && !isElectron()) { + try { + // First, try the plugin directly (works on both desktop and mobile) + console.log('[openExternalUrl] trying Tauri opener plugin...'); + const { openUrl } = await import('@tauri-apps/plugin-opener'); + await openUrl(url); + console.log('[openExternalUrl] Tauri plugin succeeded'); + return; + } catch (pluginErr) { + console.warn('[openExternalUrl] Tauri opener plugin failed:', pluginErr); + + // Fallback: try the custom command (useful if plugin fails due to ACL) + try { + console.log('[openExternalUrl] trying Tauri invoke command fallback...'); + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('open_external_url', { url }); + console.log('[openExternalUrl] Tauri command fallback succeeded'); + return; + } catch (invokeErr) { + console.error('[openExternalUrl] Tauri command fallback also failed:', invokeErr); + } + } + } + + console.log('[openExternalUrl] falling back to window.open'); + window.open(url, '_blank'); +}; + +/** + * YouTube stream info returned by yt-dlp + */ +export interface YouTubeStreamInfo { + /** Direct video stream URL */ + video_url: string; + /** Title of the video */ + title: string; +} + +/** + * Get direct YouTube stream URL using yt-dlp + * Requires yt-dlp to be installed on the system + * @param url The YouTube video URL + * @returns Stream info with direct URL and title + * @throws If yt-dlp is not installed or fails + */ +export const getYouTubeStream = async (url: string): Promise => { + if (typeof window === 'undefined') { + throw new Error('YouTube streaming requires desktop app with yt-dlp installed'); + } + + // Check for Electron + const electron = (window as any).electron; + if (electron?.youtube?.getStream) { + const result = await electron.youtube.getStream(url); + // Electron returns { success: true, data: { video_url, title } } or { success: false, error } + if (result.success === false) { + throw new Error(result.error || 'Failed to get YouTube stream'); + } + return result.data; + } + + // Check for Tauri + if ((window as any).__TAURI__) { + const { invoke } = await import('@tauri-apps/api/core'); + return invoke('get_youtube_stream', { url }); + } + + throw new Error('YouTube streaming requires desktop app with yt-dlp installed'); +}; + +/** + * Check if yt-dlp is available for YouTube streaming + * @returns True if yt-dlp streaming is available + */ +export const isYouTubeStreamingAvailable = (): boolean => { + if (typeof window === 'undefined') { + return false; + } + + // Check for Electron + const electron = (window as any).electron; + if (electron?.youtube?.getStream) { + return true; + } + + // Check for Tauri + return !!(window as any).__TAURI__; +}; + +/** + * Tauri-specific utilities for desktop and mobile platforms + */ + +/** Callback for notification tap actions */ +let notificationTapCallback: ((path: string) => void) | null = null; + +const ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot'; +const ANDROID_NOTIFICATION_ICON_COLOR = '#FF8A00'; + +/** + * Bring the Tauri window to the front and focus it + */ +export const focusWindow = async (): Promise => { + // Use Electron's window focus if in Electron + if (isElectron()) { + try { + const electron = (window as any).electron; + if (electron?.window?.focus) { + await electron.window.focus(); + } + return; + } catch (err) { + console.warn('Failed to focus Electron window:', err); + } + } + + // Use Tauri's window focus for actual Tauri builds + if (isTauri() && !isElectron()) { + try { + const { getCurrentWindow } = await import('@tauri-apps/api/window'); + const window = getCurrentWindow(); + await window.unminimize(); + await window.setFocus(); + } catch (err) { + console.warn('Failed to focus Tauri window:', err); + } + } +}; + +/** + * Set up listener for notification tap/click actions + * Call this once at app startup with a navigation callback + */ +export const setupNotificationTapListener = async (onTap: (path: string) => void): Promise => { + notificationTapCallback = onTap; + + // Use Electron's native notification handler if in Electron + if (isElectron()) { + try { + const electron = (window as any).electron; + if (electron?.notification?.onNavigate) { + electron.notification.onNavigate((data: { path?: string }) => { + if (data.path && typeof data.path === 'string' && notificationTapCallback) { + notificationTapCallback(data.path); + } + }); + return; + } + } catch (err) { + console.warn('Failed to set up Electron notification listener:', err); + return; + } + } + + // Use Tauri plugin for actual Tauri builds (not Electron) + if (isTauri() && !isElectron()) { + try { + const { onAction } = await import('@tauri-apps/plugin-notification'); + + await onAction(async (notification) => { + // Bring window to front first + await focusWindow(); + + // Get the path from extra data and navigate + const path = notification.notification?.extra?.path; + if (path && typeof path === 'string' && notificationTapCallback) { + notificationTapCallback(path); + } + }); + } catch (err) { + console.warn('Failed to set up Tauri notification tap listener:', err); + } + } + + if (isCapacitorNative()) { + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + await LocalNotifications.addListener('localNotificationActionPerformed', async (event: any) => { + await focusWindow(); + const path = event?.notification?.extra?.path; + if (path && typeof path === 'string' && notificationTapCallback) { + notificationTapCallback(path); + } + }); + } catch (err) { + console.warn('Failed to set up Capacitor notification tap listener:', err); + } + } +}; + +/** + * Check if we're running inside a Tauri application + */ +export const isTauri = (): boolean => + '__TAURI__' in window || '__TAURI_INTERNALS__' in window; + +/** + * Check if we're running inside Electron (not Tauri) + */ +export const isElectron = (): boolean => + typeof window !== 'undefined' && 'electron' in window; + +/** + * Check if we're running inside a Capacitor native app + */ +export const isCapacitorNative = (): boolean => { + if (typeof window === 'undefined') return false; + const cap = (window as any).Capacitor; + return Boolean(cap?.isNativePlatform?.() || cap?.getPlatform?.() === 'android' || cap?.getPlatform?.() === 'ios'); +}; + +/** + * Check if we're running on a mobile platform (Android/iOS) + */ +export const isTauriMobile = (): boolean => { + if (!isTauri() || isElectron()) return false; + const ua = navigator.userAgent.toLowerCase(); + return ua.includes('android') || ua.includes('iphone') || ua.includes('ipad'); +}; + +/** + * Check if we're running on Android + */ +export const isAndroid = (): boolean => { + const ua = navigator.userAgent.toLowerCase(); + return ua.includes('android'); +}; + +/** + * Apply safe area insets for mobile devices + * On Android, CSS env() may not work, so we apply fallback padding + */ +export const applySafeAreaInsets = (): void => { + if (!isTauriMobile()) return; + + const root = document.documentElement; + + // Check if env() is working by testing if it returns a value + const testValue = getComputedStyle(root).getPropertyValue('--safe-area-inset-top'); + const envWorking = testValue && testValue !== '0px' && testValue !== ''; + + if (!envWorking && isAndroid()) { + // Apply fallback padding for Android status bar and navigation bar + // Status bar is typically 24-48dp, navigation bar is typically 48dp + // We use conservative values that work on most devices + root.style.setProperty('--safe-area-inset-top', '28px'); + root.style.setProperty('--safe-area-inset-bottom', '24px'); + } +}; + +/** + * Send a browser notification + */ +const sendBrowserNotification = (options: { + title: string; + body: string; + icon?: string; + onClick?: () => void; +}): Notification | undefined => { + const { title, body, icon, onClick } = options; + + if (!('Notification' in window)) return undefined; + if (Notification.permission !== 'granted') return undefined; + + const notification = new Notification(title, { + body, + icon, + silent: true, + }); + + if (onClick) { + notification.onclick = async () => { + // Bring Tauri window to front if in Tauri + await focusWindow(); + // Also use standard window.focus for browser + window.focus(); + if (!window.closed) onClick(); + notification.close(); + }; + } + + return notification; +}; + +/** Flag to track if notification channel has been created on Android */ +let notificationChannelCreated = false; + +/** + * Create notification channel for Android + * Required for Android 8.0+ to show notifications + */ +const ensureNotificationChannel = async (): Promise => { + if (notificationChannelCreated || !isAndroid()) return; + + try { + const { createChannel, Importance } = await import('@tauri-apps/plugin-notification'); + + await createChannel({ + id: 'messages', + name: 'Messages', + description: 'Message notifications from Matrix', + importance: Importance.High, + vibration: true, + sound: 'default', + }); + + notificationChannelCreated = true; + } catch (err) { + console.warn('Failed to create notification channel:', err); + } +}; + +const ensureCapacitorNotificationChannel = async (): Promise => { + if (notificationChannelCreated || !isAndroid()) return; + + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + await LocalNotifications.createChannel({ + id: 'messages', + name: 'Messages', + description: 'Message notifications from Matrix', + importance: 5, + sound: 'default', + visibility: 1, + vibration: true, + lights: true, + }); + notificationChannelCreated = true; + } catch (err) { + console.warn('Failed to create Capacitor notification channel:', err); + } +}; + +/** + * Request notification permission across Electron, Tauri, Capacitor, and browser + */ +export const requestSystemNotificationPermission = async (): Promise => { + if (isElectron() || (isTauri() && !isElectron())) { + if (!('Notification' in window)) return false; + const permission = await Notification.requestPermission(); + return permission === 'granted'; + } + + if (isCapacitorNative()) { + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + let perm = await LocalNotifications.checkPermissions(); + if (perm.display !== 'granted') { + perm = await LocalNotifications.requestPermissions(); + } + return perm.display === 'granted'; + } catch (err) { + console.warn('Capacitor notification permission request failed:', err); + return false; + } + } + + if (!('Notification' in window)) return false; + const permission = await Notification.requestPermission(); + return permission === 'granted'; +}; + +export const getSystemNotificationPermissionState = async (): Promise => { + if (isCapacitorNative()) { + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + const perm = await LocalNotifications.checkPermissions(); + return perm.display === 'granted' ? 'granted' : 'prompt'; + } catch (err) { + console.warn('Failed to check Capacitor notification permission:', err); + return 'denied'; + } + } + + if ('Notification' in window) { + if (window.Notification.permission === 'default') { + return 'prompt'; + } + return window.Notification.permission; + } + + return 'denied'; +}; + +/** + * Send a notification using Tauri's notification plugin + * Falls back to browser Notification API if not in Tauri + */ +export const sendNotification = async (options: { + title: string; + body: string; + icon?: string; + path?: string; + onClick?: () => void; +}): Promise => { + const { title, body, icon, path, onClick } = options; + + // Use Electron's native notification API if in Electron + if (isElectron()) { + try { + const electron = (window as any).electron; + if (electron?.notification?.show) { + await electron.notification.show({ + title, + body, + icon, + path, + }); + return; + } + } catch (err) { + console.warn('Electron notification failed:', err); + } + } + + // Use Tauri plugin for actual Tauri builds (not Electron) + if (isTauri() && !isElectron()) { + try { + const { + sendNotification: tauriSendNotification, + isPermissionGranted, + requestPermission, + } = await import('@tauri-apps/plugin-notification'); + + let permissionGranted = await isPermissionGranted(); + if (!permissionGranted) { + const permission = await requestPermission(); + permissionGranted = permission === 'granted'; + } + + if (permissionGranted) { + // Ensure notification channel exists on Android + await ensureNotificationChannel(); + + await tauriSendNotification({ + title, + body, + // Use the channel on Android + channelId: isAndroid() ? 'messages' : undefined, + // Store path in extra data for notification tap handling + extra: path ? { path } : undefined, + }); + } + return; + } catch (err) { + console.warn('Tauri notification failed:', err); + } + } + + if (isCapacitorNative()) { + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + let perm = await LocalNotifications.checkPermissions(); + if (perm.display !== 'granted') { + perm = await LocalNotifications.requestPermissions(); + } + + if (perm.display === 'granted') { + await ensureCapacitorNotificationChannel(); + + const id = Math.floor(Date.now() % 2147483647); + await LocalNotifications.schedule({ + notifications: [ + { + id, + title, + body, + channelId: isAndroid() ? 'messages' : undefined, + smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined, + iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined, + extra: path ? { path } : undefined, + }, + ], + }); + } + return; + } catch (err) { + console.warn('Capacitor notification failed:', err); + } + } + + // Fallback to browser notification + sendBrowserNotification({ title, body, icon, onClick }); +}; + +/** + * Check if we're running on Linux + */ +export const isLinux = (): boolean => { + const ua = navigator.userAgent.toLowerCase(); + return ua.includes('linux') && !ua.includes('android'); +}; + +/** + * Read clipboard image on Linux using Tauri command with arboard/Wayland support + * Returns a data URL if an image is found, null otherwise + */ +export const readClipboardImage = async (): Promise => { + // Use Electron's clipboard API if in Electron + if (isElectron()) { + try { + const electron = (window as any).electron; + if (electron?.clipboard?.readImage) { + const dataUrl = await electron.clipboard.readImage(); + if (!dataUrl) return null; + + // Convert data URL to File + const response = await fetch(dataUrl); + const blob = await response.blob(); + return new File([blob], 'clipboard-image.png', { type: 'image/png' }); + } + } catch (err) { + console.warn('Failed to read Electron clipboard image:', err); + return null; + } + } + + // Use Tauri's invoke for actual Tauri builds on Linux + if (isTauri() && !isElectron() && isLinux()) { + try { + const { invoke } = await import('@tauri-apps/api/core'); + const dataUrl = await invoke('read_clipboard_image'); + + if (!dataUrl) return null; + + // Convert data URL to File + const response = await fetch(dataUrl); + const blob = await response.blob(); + return new File([blob], 'clipboard-image.png', { type: 'image/png' }); + } catch (err) { + console.warn('Failed to read Tauri clipboard image:', err); + return null; + } + } + + return null; +}; + +/** + * Background Sync API for mobile platforms + * Starts a native Rust-based Matrix sync that runs even when the app is backgrounded + */ +export interface BackgroundSyncCredentials { + homeserverUrl: string; + userId: string; + accessToken: string; + deviceId: string; +} + +/** + * Start background Matrix sync on mobile + * This runs the sync in native Rust code, allowing notifications even when the app is backgrounded + */ +export const startBackgroundSync = async (credentials: BackgroundSyncCredentials): Promise => { + if (!isTauriMobile()) { + console.log('[BackgroundSync] Not on mobile, skipping'); + return; + } + + try { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('start_background_sync', { + homeserverUrl: credentials.homeserverUrl, + userId: credentials.userId, + accessToken: credentials.accessToken, + deviceId: credentials.deviceId, + }); + console.log('[BackgroundSync] Started successfully'); + } catch (err) { + console.error('[BackgroundSync] Failed to start:', err); + throw err; + } +}; + +/** + * Stop background Matrix sync on mobile + */ +export const stopBackgroundSync = async (): Promise => { + if (!isTauriMobile()) return; + + try { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('stop_background_sync'); + console.log('[BackgroundSync] Stopped'); + } catch (err) { + console.error('[BackgroundSync] Failed to stop:', err); + } +}; + +/** + * Get the current background sync state + */ +export const getBackgroundSyncState = async (): Promise => { + if (!isTauriMobile()) return 'NotApplicable'; + + try { + const { invoke } = await import('@tauri-apps/api/core'); + return await invoke('get_background_sync_state'); + } catch (err) { + console.error('[BackgroundSync] Failed to get state:', err); + return 'Error'; + } +}; diff --git a/overlay/tsconfig.json b/overlay/tsconfig.json new file mode 100644 index 0000000..2324e05 --- /dev/null +++ b/overlay/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "sourceMap": true, + "jsx": "react", + "target": "ES2016", + "module": "ES2020", + "allowJs": true, + "strict": true, + "esModuleInterop": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "./src", + "skipLibCheck": true, + "lib": ["ES2016", "DOM"] + }, + "exclude": ["node_modules", "dist"], + "include": ["src"] +} diff --git a/package.json b/package.json index 37420ea..b9c3ede 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "node": ">=18.0.0" }, "scripts": { - "android:prepare": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && npx cap sync android", + "android:prepare": "node scripts/apply-overlay.mjs && cd cinny && npm install && npm run build && npx cap sync android", "android:open": "cd cinny && npx cap open android", "android:run": "cd cinny && npx cap run android", "android:apk": "npm run android:prepare && cd cinny/android && gradlew.bat assembleDebug", diff --git a/scripts/apply-overlay.mjs b/scripts/apply-overlay.mjs new file mode 100644 index 0000000..8469e22 --- /dev/null +++ b/scripts/apply-overlay.mjs @@ -0,0 +1,102 @@ +/** + * Applies mobile-specific overlay files from the overlay/ directory on top of the cinny submodule. + * + * This script: + * 1. Copies config.json (root) into cinny/config.json + * 2. Copies all source files from overlay/src/ into cinny/src/ + * 3. Copies overlay/tsconfig.json into cinny/tsconfig.json + * 4. Merges overlay/package-additions.json dependencies into cinny/package.json + * 5. Copies capacitor.config.json (root) into cinny/capacitor.config.json with webDir reset to "dist" + * 6. Creates a Windows directory junction cinny/android -> root android/ so Capacitor can find it + * + * Run before every Android build to keep the cinny submodule clean while layering + * the Capacitor / Android platform changes on top of it. + */ + +import { readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { join, dirname, relative, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { execSync } from 'child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const CINNY = join(ROOT, 'cinny'); +const OVERLAY = join(ROOT, 'overlay'); + +/** + * Recursively copy all files from src directory to dest directory. + * @param {string} src + * @param {string} dest + */ +function copyDirRecursive(src, dest) { + mkdirSync(dest, { recursive: true }); + for (const entry of readdirSync(src)) { + const srcPath = join(src, entry); + const destPath = join(dest, entry); + if (statSync(srcPath).isDirectory()) { + copyDirRecursive(srcPath, destPath); + } else { + copyFileSync(srcPath, destPath); + console.log(` overlay: ${relative(ROOT, destPath)}`); + } + } +} + +// 1. Copy config.json to cinny/config.json +console.log('[apply-overlay] Copying config.json...'); +copyFileSync(join(ROOT, 'config.json'), join(CINNY, 'config.json')); +console.log(` copied config.json -> cinny/config.json`); + +// 2. Copy overlay source files into cinny/src/ +console.log('[apply-overlay] Copying source files...'); +copyDirRecursive(join(OVERLAY, 'src'), join(CINNY, 'src')); + +// 3. Copy tsconfig.json +console.log('[apply-overlay] Copying tsconfig.json...'); +copyFileSync(join(OVERLAY, 'tsconfig.json'), join(CINNY, 'tsconfig.json')); +console.log(` overlay: cinny/tsconfig.json`); + +// 4. Merge package-additions.json into cinny/package.json +console.log('[apply-overlay] Merging package dependencies...'); +const cinnyPkg = JSON.parse(readFileSync(join(CINNY, 'package.json'), 'utf8')); +const additions = JSON.parse(readFileSync(join(OVERLAY, 'package-additions.json'), 'utf8')); + +cinnyPkg.dependencies = { + ...cinnyPkg.dependencies, + ...(additions.dependencies ?? {}), +}; +if (additions.devDependencies) { + cinnyPkg.devDependencies = { + ...cinnyPkg.devDependencies, + ...additions.devDependencies, + }; +} + +writeFileSync(join(CINNY, 'package.json'), JSON.stringify(cinnyPkg, null, 2) + '\n', 'utf8'); +console.log(` merged ${Object.keys(additions.dependencies ?? {}).length} dep(s) into cinny/package.json`); + +// 5. Copy capacitor.config.json to cinny/ with webDir corrected back to "dist" +console.log('[apply-overlay] Writing cinny/capacitor.config.json...'); +const rootCapConfig = JSON.parse(readFileSync(join(ROOT, 'capacitor.config.json'), 'utf8')); +const cinnyCapConfig = { ...rootCapConfig, webDir: 'dist' }; +writeFileSync(join(CINNY, 'capacitor.config.json'), JSON.stringify(cinnyCapConfig, null, 2) + '\n', 'utf8'); +console.log(` wrote cinny/capacitor.config.json (webDir: "dist")`); + +// 6. Create a directory junction cinny/android -> root android/ so Capacitor CLI can find it +console.log('[apply-overlay] Setting up android/ junction...'); +const androidJunction = join(CINNY, 'android'); +const androidTarget = resolve(ROOT, 'android'); +if (!existsSync(androidJunction)) { + try { + execSync(`cmd /c mklink /J "${androidJunction}" "${androidTarget}"`, { stdio: 'pipe' }); + console.log(` created junction: cinny/android -> ${androidTarget}`); + } catch (err) { + console.warn(' mklink /J failed, falling back to directory copy...'); + copyDirRecursive(androidTarget, androidJunction); + console.log(` copied android/ into cinny/android/`); + } +} else { + console.log(` android/ junction already exists, skipping`); +} + +console.log('[apply-overlay] Done.');