diff --git a/.gitignore b/.gitignore index 56df3dab..2da37bb8 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,8 @@ stats.html # Service worker build artifact (generated from src/sw.ts) public/sw.js + +# Capacitor native build artifacts (projects themselves are committed) +android/app/build/ +android/.gradle/ +android/build/ diff --git a/README.md b/README.md index a366fd87..5ff6a42e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details. - [Technical Architecture](docs/TECHNICAL_ARCHITECTURE.md) - [Why Coho?](docs/WHY_COHO.md) +- [Safety Standards (CSAE)](SAFETY_STANDARDS.md)
AI Use Disclaimer diff --git a/SAFETY_STANDARDS.md b/SAFETY_STANDARDS.md new file mode 100644 index 00000000..11914894 --- /dev/null +++ b/SAFETY_STANDARDS.md @@ -0,0 +1,31 @@ +# Safety Standards + +## Child Sexual Abuse and Exploitation (CSAE) Policy + +Coho is a fast, offline-first Mastodon client designed to connect users to the Fediverse. As a client application, Coho does not host any user-generated content directly. However, we are deeply committed to maintaining a safe environment and strictly prohibit any use of our application to facilitate, distribute, or access Child Sexual Abuse Material (CSAM) or any form of Child Sexual Abuse and Exploitation (CSAE). + +### Zero Tolerance Policy + +We have a zero-tolerance policy against CSAE. Any use of the Coho app to promote, facilitate, or share content related to the sexual abuse or exploitation of children is strictly prohibited. + +### Content Moderation and Reporting + +Because Coho connects to third-party Mastodon servers, content moderation is primarily handled by the administrators of those individual servers. However, Coho enforces safety at the client level by: + +- **Blocking Known Offending Instances**: Coho reserves the right to block connections to any Mastodon instances that are known to host or tolerate CSAM or other forms of CSAE. +- **Reporting Mechanisms**: Users are encouraged to report any instances of CSAE encountered on the Fediverse. These reports should be directed to: + 1. The administrator of the instance hosting the content. + 2. The appropriate law enforcement agencies, such as the [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/) in the United States, or local equivalent authorities. + 3. Coho developers at jgw9617@gmail.com, so we may evaluate whether the offending instance should be disconnected or blocked from the client. + +### Enforcement + +Violations of this policy will result in immediate action, which may include: +- Blocking access to the offending instances from within the Coho app. +- Cooperating fully with law enforcement and reporting authorities in any investigations related to CSAE. + +### User Responsibility + +Users of Coho are expected to adhere to the rules and policies of the Mastodon instances they connect to, as well as local and international laws. Engaging in any activity involving CSAE is a severe criminal offense and will not be tolerated. + +For more information on our community standards and how we ensure a safe environment, please refer to our [Code of Conduct](./CODE_OF_CONDUCT.md). diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..48354a3d --- /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 00000000..043df802 --- /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 00000000..c2889e9a --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,66 @@ +apply plugin: 'com.android.application' + +android { + namespace = "place.coho.app" + compileSdk = rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "place.coho.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 6 + versionName "4.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-optimize.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') + + // On-device AI: ML Kit Language Identification + Translation + implementation 'com.google.mlkit:language-id:17.0.6' + implementation 'com.google.mlkit:translate:17.0.3' + + // On-device AI: ML Kit GenAI (Gemini Nano) – image description + proofreading + implementation 'com.google.mlkit:genai-image-description:1.0.0-beta1' + implementation 'com.google.mlkit:genai-proofreading:1.0.0-beta1' + + // Wearable Data Layer (phone → watch credential sync) + implementation 'com.google.android.gms:play-services-wearable:19.0.0' + + 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 00000000..4d496a73 --- /dev/null +++ b/android/app/capacitor.build.gradle @@ -0,0 +1,22 @@ +// 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-app') + implementation project(':capacitor-browser') + implementation project(':capacitor-push-notifications') + implementation project(':capacitor-share') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 00000000..6293f4cf --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "117311718455", + "project_id": "coho-mastodon", + "storage_bucket": "coho-mastodon.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:117311718455:android:ca27519307eab20e21ebb2", + "android_client_info": { + "package_name": "place.coho.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBu3u8WpBs5hkiLa6r3O8MKR-Mrd3xPWyg" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /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 00000000..f2c2217e --- /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 00000000..5d956cf6 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 00000000..56c78c21 Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/java/place/coho/app/AiBridge.java b/android/app/src/main/java/place/coho/app/AiBridge.java new file mode 100644 index 00000000..5435d840 --- /dev/null +++ b/android/app/src/main/java/place/coho/app/AiBridge.java @@ -0,0 +1,366 @@ +package place.coho.app; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.util.Base64; +import android.util.Log; + +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +import com.google.mlkit.nl.languageid.LanguageIdentification; +import com.google.mlkit.nl.languageid.LanguageIdentifier; +import com.google.mlkit.nl.translate.TranslateLanguage; +import com.google.mlkit.nl.translate.Translation; +import com.google.mlkit.nl.translate.Translator; +import com.google.mlkit.nl.translate.TranslatorOptions; + +import com.google.mlkit.genai.common.FeatureStatus; +import com.google.mlkit.genai.imagedescription.ImageDescriber; +import com.google.mlkit.genai.imagedescription.ImageDescriberOptions; +import com.google.mlkit.genai.imagedescription.ImageDescription; +import com.google.mlkit.genai.imagedescription.ImageDescriptionRequest; +import com.google.mlkit.genai.proofreading.Proofreader; +import com.google.mlkit.genai.proofreading.ProofreaderOptions; +import com.google.mlkit.genai.proofreading.Proofreading; +import com.google.mlkit.genai.proofreading.ProofreadingRequest; +import com.google.mlkit.genai.proofreading.ProofreadingResult; +import com.google.mlkit.genai.proofreading.ProofreadingSuggestion; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Capacitor plugin that exposes Android on-device AI capabilities to the web layer: + * - Translation via ML Kit + * - Alt text generation via ML Kit GenAI Image Description (Gemini Nano) + * - Proofreading via ML Kit GenAI Proofreading (Gemini Nano) + */ +@CapacitorPlugin(name = "AiBridge") +public class AiBridge extends Plugin { + + private static final String TAG = "AiBridge"; + private static final ExecutorService executor = Executors.newCachedThreadPool(); + + // ── Language Detection (ML Kit) ───────────────────────────────────── + + @PluginMethod + public void detectLanguage(PluginCall call) { + String text = call.getString("text"); + if (text == null || text.isEmpty()) { + call.reject("text is required"); + return; + } + + LanguageIdentifier identifier = LanguageIdentification.getClient(); + identifier.identifyLanguage(text) + .addOnSuccessListener(languageCode -> { + JSObject result = new JSObject(); + // ML Kit returns "und" for undetermined + result.put("language", "und".equals(languageCode) ? "en" : languageCode); + call.resolve(result); + identifier.close(); + }) + .addOnFailureListener(e -> { + Log.e(TAG, "Language detection failed", e); + JSObject result = new JSObject(); + result.put("language", "en"); + call.resolve(result); + identifier.close(); + }); + } + + // ── Translation (ML Kit) ────────────────────────────────────────────── + + @PluginMethod + public void translate(PluginCall call) { + String text = call.getString("text"); + String sourceLang = call.getString("sourceLanguage", "en"); + String targetLang = call.getString("targetLanguage", "en"); + + if (text == null || text.isEmpty()) { + call.reject("text is required"); + return; + } + + String sourceTag = mapToMlKitLanguage(sourceLang); + String targetTag = mapToMlKitLanguage(targetLang); + + if (sourceTag == null || targetTag == null) { + call.reject("Unsupported language pair: " + sourceLang + " -> " + targetLang); + return; + } + + TranslatorOptions options = new TranslatorOptions.Builder() + .setSourceLanguage(sourceTag) + .setTargetLanguage(targetTag) + .build(); + + Translator translator = Translation.getClient(options); + + // Ensure models are downloaded, then translate + translator.downloadModelIfNeeded() + .addOnSuccessListener(unused -> + translator.translate(text) + .addOnSuccessListener(translatedText -> { + JSObject result = new JSObject(); + result.put("translatedText", translatedText); + call.resolve(result); + translator.close(); + }) + .addOnFailureListener(e -> { + Log.e(TAG, "Translation failed", e); + call.reject("Translation failed: " + e.getMessage()); + translator.close(); + }) + ) + .addOnFailureListener(e -> { + Log.e(TAG, "Model download failed", e); + call.reject("Translation model download failed: " + e.getMessage()); + translator.close(); + }); + } + + // ── Alt Text Generation (ML Kit GenAI Image Description) ───────────── + + @PluginMethod + public void generateAltText(PluginCall call) { + String imageBase64 = call.getString("imageBase64"); + + if (imageBase64 == null || imageBase64.isEmpty()) { + call.reject("imageBase64 is required"); + return; + } + + try { + // Strip data URI prefix if present + String base64Data = imageBase64; + if (base64Data.contains(",")) { + base64Data = base64Data.substring(base64Data.indexOf(",") + 1); + } + + byte[] imageBytes = Base64.decode(base64Data, Base64.DEFAULT); + Bitmap bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length); + + if (bitmap == null) { + call.reject("Failed to decode image from base64"); + return; + } + + executor.execute(() -> { + ImageDescriber describer = null; + try { + ImageDescriberOptions options = ImageDescriberOptions.builder(getContext()).build(); + describer = ImageDescription.getClient(options); + + int status = describer.checkFeatureStatus().get(); + if (status == FeatureStatus.UNAVAILABLE) { + call.reject("Image description not available on this device"); + return; + } + + ImageDescriptionRequest request = ImageDescriptionRequest.builder(bitmap).build(); + String description = describer.runInference(request).get().getDescription(); + + JSObject result = new JSObject(); + result.put("altText", description != null ? description.trim() : ""); + call.resolve(result); + } catch (Exception e) { + Log.e(TAG, "Alt text generation failed", e); + call.reject("Alt text generation failed: " + e.getMessage()); + } finally { + if (describer != null) describer.close(); + } + }); + + } catch (Exception e) { + Log.e(TAG, "Alt text generation error", e); + call.reject("Alt text generation error: " + e.getMessage()); + } + } + + // ── Proofreading (ML Kit GenAI) ─────────────────────────────────────── + + @PluginMethod + public void proofread(PluginCall call) { + String text = call.getString("text"); + + if (text == null || text.isEmpty()) { + call.reject("text is required"); + return; + } + + executor.execute(() -> { + Proofreader proofreader = null; + try { + ProofreaderOptions options = ProofreaderOptions.builder(getContext()) + .setInputType(ProofreaderOptions.InputType.KEYBOARD) + .setLanguage(ProofreaderOptions.Language.ENGLISH) + .build(); + proofreader = Proofreading.getClient(options); + + int status = proofreader.checkFeatureStatus().get(); + if (status == FeatureStatus.UNAVAILABLE) { + call.reject("Proofreading not available on this device"); + return; + } + + ProofreadingRequest request = ProofreadingRequest.builder(text).build(); + ProofreadingResult proofResult = proofreader.runInference(request).get(); + List suggestions = proofResult.getResults(); + + JSObject result = new JSObject(); + if (suggestions != null && !suggestions.isEmpty()) { + // Top suggestion is sorted by highest confidence + result.put("correctedInput", suggestions.get(0).getText()); + } else { + result.put("correctedInput", text); + } + // ML Kit returns whole-text suggestions, not granular corrections + result.put("corrections", new JSArray()); + call.resolve(result); + } catch (Exception e) { + Log.e(TAG, "Proofreading failed", e); + call.reject("Proofreading failed: " + e.getMessage()); + } finally { + if (proofreader != null) proofreader.close(); + } + }); + } + + // ── Availability Check ──────────────────────────────────────────────── + + @PluginMethod + public void checkAvailability(PluginCall call) { + executor.execute(() -> { + boolean altTextAvailable = false; + boolean proofreadingAvailable = false; + + // Check image description availability + ImageDescriber describer = null; + try { + ImageDescriberOptions idOptions = ImageDescriberOptions.builder(getContext()).build(); + describer = ImageDescription.getClient(idOptions); + int status = describer.checkFeatureStatus().get(); + Log.i(TAG, "Image description feature status: " + status + + " (AVAILABLE=" + FeatureStatus.AVAILABLE + + ", DOWNLOADABLE=" + FeatureStatus.DOWNLOADABLE + + ", DOWNLOADING=" + FeatureStatus.DOWNLOADING + + ", UNAVAILABLE=" + FeatureStatus.UNAVAILABLE + ")"); + altTextAvailable = (status != FeatureStatus.UNAVAILABLE); + } catch (Exception e) { + Log.e(TAG, "Image description check failed", e); + } finally { + if (describer != null) describer.close(); + } + + // Check proofreading availability + Proofreader proofreader = null; + try { + ProofreaderOptions prOptions = ProofreaderOptions.builder(getContext()) + .setInputType(ProofreaderOptions.InputType.KEYBOARD) + .setLanguage(ProofreaderOptions.Language.ENGLISH) + .build(); + proofreader = Proofreading.getClient(prOptions); + int status = proofreader.checkFeatureStatus().get(); + Log.i(TAG, "Proofreading feature status: " + status + + " (AVAILABLE=" + FeatureStatus.AVAILABLE + + ", DOWNLOADABLE=" + FeatureStatus.DOWNLOADABLE + + ", DOWNLOADING=" + FeatureStatus.DOWNLOADING + + ", UNAVAILABLE=" + FeatureStatus.UNAVAILABLE + ")"); + proofreadingAvailable = (status != FeatureStatus.UNAVAILABLE); + } catch (Exception e) { + Log.e(TAG, "Proofreading check failed", e); + } finally { + if (proofreader != null) proofreader.close(); + } + + Log.i(TAG, "AI capabilities: translation=true, altText=" + altTextAvailable + + ", proofreading=" + proofreadingAvailable); + + JSObject result = new JSObject(); + result.put("translation", true); + result.put("altText", altTextAvailable); + result.put("proofreading", proofreadingAvailable); + call.resolve(result); + }); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + /** + * Map a BCP-47 language tag (or common shorthand) to an ML Kit + * TranslateLanguage constant. Returns null if unsupported. + */ + private String mapToMlKitLanguage(String langCode) { + if (langCode == null) return null; + // Normalize: take first segment, lowercase + String code = langCode.split("-")[0].toLowerCase(); + switch (code) { + case "af": return TranslateLanguage.AFRIKAANS; + case "ar": return TranslateLanguage.ARABIC; + case "be": return TranslateLanguage.BELARUSIAN; + case "bg": return TranslateLanguage.BULGARIAN; + case "bn": return TranslateLanguage.BENGALI; + case "ca": return TranslateLanguage.CATALAN; + case "cs": return TranslateLanguage.CZECH; + case "cy": return TranslateLanguage.WELSH; + case "da": return TranslateLanguage.DANISH; + case "de": return TranslateLanguage.GERMAN; + case "el": return TranslateLanguage.GREEK; + case "en": return TranslateLanguage.ENGLISH; + case "eo": return TranslateLanguage.ESPERANTO; + case "es": return TranslateLanguage.SPANISH; + case "et": return TranslateLanguage.ESTONIAN; + case "fa": return TranslateLanguage.PERSIAN; + case "fi": return TranslateLanguage.FINNISH; + case "fr": return TranslateLanguage.FRENCH; + case "ga": return TranslateLanguage.IRISH; + case "gl": return TranslateLanguage.GALICIAN; + case "gu": return TranslateLanguage.GUJARATI; + case "he": return TranslateLanguage.HEBREW; + case "hi": return TranslateLanguage.HINDI; + case "hr": return TranslateLanguage.CROATIAN; + case "hu": return TranslateLanguage.HUNGARIAN; + case "id": return TranslateLanguage.INDONESIAN; + case "is": return TranslateLanguage.ICELANDIC; + case "it": return TranslateLanguage.ITALIAN; + case "ja": return TranslateLanguage.JAPANESE; + case "ka": return TranslateLanguage.GEORGIAN; + case "kn": return TranslateLanguage.KANNADA; + case "ko": return TranslateLanguage.KOREAN; + case "lt": return TranslateLanguage.LITHUANIAN; + case "lv": return TranslateLanguage.LATVIAN; + case "mk": return TranslateLanguage.MACEDONIAN; + case "mr": return TranslateLanguage.MARATHI; + case "ms": return TranslateLanguage.MALAY; + case "mt": return TranslateLanguage.MALTESE; + case "nl": return TranslateLanguage.DUTCH; + case "no": return TranslateLanguage.NORWEGIAN; + case "pl": return TranslateLanguage.POLISH; + case "pt": return TranslateLanguage.PORTUGUESE; + case "ro": return TranslateLanguage.ROMANIAN; + case "ru": return TranslateLanguage.RUSSIAN; + case "sk": return TranslateLanguage.SLOVAK; + case "sl": return TranslateLanguage.SLOVENIAN; + case "sq": return TranslateLanguage.ALBANIAN; + case "sv": return TranslateLanguage.SWEDISH; + case "sw": return TranslateLanguage.SWAHILI; + case "ta": return TranslateLanguage.TAMIL; + case "te": return TranslateLanguage.TELUGU; + case "th": return TranslateLanguage.THAI; + case "tl": return TranslateLanguage.TAGALOG; + case "tr": return TranslateLanguage.TURKISH; + case "uk": return TranslateLanguage.UKRAINIAN; + case "ur": return TranslateLanguage.URDU; + case "vi": return TranslateLanguage.VIETNAMESE; + case "zh": return TranslateLanguage.CHINESE; + default: return null; + } + } +} diff --git a/android/app/src/main/java/place/coho/app/DynamicThemeBridge.java b/android/app/src/main/java/place/coho/app/DynamicThemeBridge.java new file mode 100644 index 00000000..a458ea6e --- /dev/null +++ b/android/app/src/main/java/place/coho/app/DynamicThemeBridge.java @@ -0,0 +1,38 @@ +package place.coho.app; + +import android.os.Build; + +import androidx.core.content.ContextCompat; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +/** + * Capacitor plugin that exposes the Android Material You dynamic accent color + * to the web layer. On API 31+ (Android 12+), reads the system wallpaper-derived + * accent color. On older versions, returns null so the web app can fall back. + */ +@CapacitorPlugin(name = "DynamicThemeBridge") +public class DynamicThemeBridge extends Plugin { + + @PluginMethod + public void getAccentColor(PluginCall call) { + JSObject result = new JSObject(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + int colorInt = ContextCompat.getColor( + getContext(), android.R.color.system_accent1_500); + String hex = String.format("#%06X", (0xFFFFFF & colorInt)); + result.put("color", hex); + result.put("supported", true); + } else { + result.put("color", null); + result.put("supported", false); + } + + call.resolve(result); + } +} diff --git a/android/app/src/main/java/place/coho/app/MainActivity.java b/android/app/src/main/java/place/coho/app/MainActivity.java new file mode 100644 index 00000000..7e006f93 --- /dev/null +++ b/android/app/src/main/java/place/coho/app/MainActivity.java @@ -0,0 +1,106 @@ +package place.coho.app; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.webkit.WebView; + +import androidx.activity.OnBackPressedCallback; + +import com.getcapacitor.Bridge; +import com.getcapacitor.BridgeActivity; +import com.getcapacitor.BridgeWebViewClient; + +public class MainActivity extends BridgeActivity { + + private OnBackPressedCallback webViewBackCallback; + + @Override + protected void onCreate(Bundle savedInstanceState) { + registerPlugin(WidgetBridge.class); + registerPlugin(DynamicThemeBridge.class); + registerPlugin(ShareTargetBridge.class); + registerPlugin(AiBridge.class); + registerPlugin(WearSyncBridge.class); + super.onCreate(savedInstanceState); + + // If launched from a shortcut (ACTION_VIEW with localhost data), + // navigate the WebView to the shortcut's target path. + handleShortcutIntent(getIntent()); + + // Predictive back gesture support (Android 14+). + // The callback is only enabled when the WebView has history, so the + // system can show the correct predictive animation: + // - enabled → in-app back: navigates WebView history + // - disabled → system "back to home" peek animation, then finish() + WebView webView = getBridge().getWebView(); + webViewBackCallback = new OnBackPressedCallback(webView.canGoBack()) { + @Override + public void handleOnBackPressed() { + WebView wv = getBridge().getWebView(); + if (wv.canGoBack()) { + wv.goBack(); + } + // Update enabled state after navigation + setEnabled(wv.canGoBack()); + } + }; + getOnBackPressedDispatcher().addCallback(this, webViewBackCallback); + + // Install a custom WebViewClient that tracks both real page loads and + // SPA pushState/replaceState navigations via doUpdateVisitedHistory, + // keeping the back callback's enabled state in sync with WebView history. + Bridge bridge = getBridge(); + bridge.setWebViewClient(new BridgeWebViewClient(bridge) { + @Override + public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) { + super.doUpdateVisitedHistory(view, url, isReload); + webViewBackCallback.setEnabled(view.canGoBack()); + } + }); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + + String action = intent.getAction(); + if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) { + // Notify the web layer that new shared content arrived + ShareTargetBridge plugin = (ShareTargetBridge) getBridge().getPlugin("ShareTargetBridge").getInstance(); + if (plugin != null) { + plugin.notifyShareIntent(); + } + } else if (Intent.ACTION_VIEW.equals(action)) { + // Handle shortcut taps when the app is already running + handleShortcutIntent(intent); + } + } + + /** + * If the intent carries a localhost URL (from a static shortcut), + * extract the path + query and navigate the WebView there. + */ + private void handleShortcutIntent(Intent intent) { + if (intent == null || !Intent.ACTION_VIEW.equals(intent.getAction())) { + return; + } + Uri data = intent.getData(); + if (data == null) return; + + String host = data.getHost(); + if (!"localhost".equals(host)) return; + + String path = data.getPath(); + if (path == null || path.equals("/")) return; + + String query = data.getQuery(); + String url = path + (query != null ? "?" + query : ""); + + // Wait for the bridge to be ready, then navigate + getBridge().getWebView().post(() -> { + getBridge().getWebView().loadUrl("https://localhost" + url); + }); + } +} diff --git a/android/app/src/main/java/place/coho/app/ShareTargetBridge.java b/android/app/src/main/java/place/coho/app/ShareTargetBridge.java new file mode 100644 index 00000000..d970fc8f --- /dev/null +++ b/android/app/src/main/java/place/coho/app/ShareTargetBridge.java @@ -0,0 +1,185 @@ +package place.coho.app; + +import android.content.ContentResolver; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.webkit.MimeTypeMap; + +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Capacitor plugin that extracts data from Android ACTION_SEND intents + * so the web layer can process shared text, URLs, images, and videos. + */ +@CapacitorPlugin(name = "ShareTargetBridge") +public class ShareTargetBridge extends Plugin { + + @PluginMethod + public void getSharedContent(PluginCall call) { + Intent intent = getActivity().getIntent(); + JSObject result = new JSObject(); + + if (intent == null) { + result.put("hasShare", false); + call.resolve(result); + return; + } + + String action = intent.getAction(); + String type = intent.getType(); + + if (action == null || type == null) { + result.put("hasShare", false); + call.resolve(result); + return; + } + + boolean isSend = Intent.ACTION_SEND.equals(action); + boolean isSendMultiple = Intent.ACTION_SEND_MULTIPLE.equals(action); + + if (!isSend && !isSendMultiple) { + result.put("hasShare", false); + call.resolve(result); + return; + } + + result.put("hasShare", true); + + // Extract shared text (often a URL) + String text = intent.getStringExtra(Intent.EXTRA_TEXT); + String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT); + if (text != null) { + result.put("text", text); + } + if (subject != null) { + result.put("subject", subject); + } + + // Extract shared media files + JSArray filesArray = new JSArray(); + List uris = new ArrayList<>(); + + if (isSend) { + Uri singleUri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (singleUri != null) { + uris.add(singleUri); + } + } else if (isSendMultiple) { + List multiUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (multiUris != null) { + uris.addAll(multiUris); + } + } + + ContentResolver resolver = getContext().getContentResolver(); + File cacheDir = new File(getContext().getCacheDir(), "share_target"); + if (!cacheDir.exists()) { + cacheDir.mkdirs(); + } + + for (Uri uri : uris) { + try { + String mimeType = resolver.getType(uri); + String fileName = getFileName(resolver, uri); + if (fileName == null) { + // Generate a name from MIME type + String ext = MimeTypeMap.getSingleton() + .getExtensionFromMimeType(mimeType); + fileName = UUID.randomUUID().toString() + (ext != null ? "." + ext : ""); + } + + // Copy to internal cache + File destFile = new File(cacheDir, fileName); + try (InputStream in = resolver.openInputStream(uri); + FileOutputStream out = new FileOutputStream(destFile)) { + if (in == null) continue; + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + } + + JSObject fileObj = new JSObject(); + fileObj.put("name", fileName); + fileObj.put("type", mimeType != null ? mimeType : "application/octet-stream"); + fileObj.put("path", destFile.getAbsolutePath()); + fileObj.put("size", destFile.length()); + filesArray.put(fileObj); + } catch (IOException e) { + // Skip files that fail to copy + System.err.println("[ShareTargetBridge] Failed to copy shared file: " + e.getMessage()); + } + } + + if (filesArray.length() > 0) { + result.put("files", filesArray); + } + + call.resolve(result); + } + + @PluginMethod + public void clearSharedContent(PluginCall call) { + // Clear the intent so re-opening the app doesn't re-trigger the share + Intent intent = getActivity().getIntent(); + if (intent != null) { + intent.setAction(null); + intent.removeExtra(Intent.EXTRA_TEXT); + intent.removeExtra(Intent.EXTRA_SUBJECT); + intent.removeExtra(Intent.EXTRA_STREAM); + } + + // Clean up cached files + File cacheDir = new File(getContext().getCacheDir(), "share_target"); + if (cacheDir.exists()) { + File[] files = cacheDir.listFiles(); + if (files != null) { + for (File file : files) { + file.delete(); + } + } + } + + call.resolve(); + } + + /** + * Notify the web layer that a new share intent arrived while the app + * was already running (via onNewIntent in MainActivity). + */ + public void notifyShareIntent() { + notifyListeners("shareIntent", new JSObject()); + } + + private static String getFileName(ContentResolver resolver, Uri uri) { + if ("content".equals(uri.getScheme())) { + try (Cursor cursor = resolver.query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (idx >= 0) { + return cursor.getString(idx); + } + } + } + } + // Fall back to last path segment + String path = uri.getLastPathSegment(); + return path; + } +} diff --git a/android/app/src/main/java/place/coho/app/TrendingDataFetcher.java b/android/app/src/main/java/place/coho/app/TrendingDataFetcher.java new file mode 100644 index 00000000..f24df584 --- /dev/null +++ b/android/app/src/main/java/place/coho/app/TrendingDataFetcher.java @@ -0,0 +1,75 @@ +package place.coho.app; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +public class TrendingDataFetcher { + + public static class TrendingTag { + public final String name; + public final String uses; + + public TrendingTag(String name, String uses) { + this.name = name; + this.uses = uses; + } + } + + /** + * Fetches trending tags from the Mastodon API. + * This endpoint is public and does not require authentication. + */ + public static List fetchTrendingTags(String server) { + List tags = new ArrayList<>(); + HttpURLConnection conn = null; + try { + URL url = new URL("https://" + server + "/api/v1/trends/tags"); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Accept", "application/json"); + + if (conn.getResponseCode() == 200) { + InputStream is = conn.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, "UTF-8")); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + reader.close(); + + JSONArray array = new JSONArray(sb.toString()); + int limit = Math.min(array.length(), 5); + for (int i = 0; i < limit; i++) { + JSONObject obj = array.getJSONObject(i); + String name = obj.optString("name", ""); + String uses = "0"; + JSONArray history = obj.optJSONArray("history"); + if (history != null && history.length() > 0) { + uses = history.getJSONObject(0).optString("uses", "0"); + } + if (!name.isEmpty()) { + tags.add(new TrendingTag(name, uses)); + } + } + } + } catch (Exception e) { + // Network or parse error — return empty list, widget will show fallback + } finally { + if (conn != null) { + conn.disconnect(); + } + } + return tags; + } +} diff --git a/android/app/src/main/java/place/coho/app/TrendingWidget.java b/android/app/src/main/java/place/coho/app/TrendingWidget.java new file mode 100644 index 00000000..ef6c8d52 --- /dev/null +++ b/android/app/src/main/java/place/coho/app/TrendingWidget.java @@ -0,0 +1,99 @@ +package place.coho.app; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.view.View; +import android.widget.RemoteViews; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class TrendingWidget extends AppWidgetProvider { + + private static final ExecutorService executor = Executors.newSingleThreadExecutor(); + static final String PREFS_NAME = "coho_widget"; + static final String PREF_SERVER = "server"; + static final String DEFAULT_SERVER = "mastodon.social"; + + private static final int[] ITEM_IDS = { + R.id.item_1, R.id.item_2, R.id.item_3, R.id.item_4, R.id.item_5 + }; + private static final int[] TAG_NAME_IDS = { + R.id.tag_name_1, R.id.tag_name_2, R.id.tag_name_3, R.id.tag_name_4, R.id.tag_name_5 + }; + private static final int[] TAG_COUNT_IDS = { + R.id.tag_count_1, R.id.tag_count_2, R.id.tag_count_3, R.id.tag_count_4, R.id.tag_count_5 + }; + + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + for (int appWidgetId : appWidgetIds) { + updateWidget(context, appWidgetManager, appWidgetId); + } + } + + static void updateWidget(Context context, AppWidgetManager manager, int widgetId) { + // Show loading placeholder immediately + RemoteViews loading = buildLoadingView(context); + manager.updateAppWidget(widgetId, loading); + + executor.execute(() -> { + String server = getServer(context); + List tags = TrendingDataFetcher.fetchTrendingTags(server); + + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_trending); + + // Set up tap-to-open intent on the entire widget + Intent intent = new Intent(context, MainActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pendingIntent = PendingIntent.getActivity( + context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + views.setOnClickPendingIntent(R.id.widget_root, pendingIntent); + + if (tags.isEmpty()) { + // Hide all items, show empty state + for (int itemId : ITEM_IDS) { + views.setViewVisibility(itemId, View.GONE); + } + views.setViewVisibility(R.id.widget_empty, View.VISIBLE); + } else { + views.setViewVisibility(R.id.widget_empty, View.GONE); + for (int i = 0; i < ITEM_IDS.length; i++) { + if (i < tags.size()) { + TrendingDataFetcher.TrendingTag tag = tags.get(i); + views.setViewVisibility(ITEM_IDS[i], View.VISIBLE); + views.setTextViewText(TAG_NAME_IDS[i], "#" + tag.name); + views.setTextViewText(TAG_COUNT_IDS[i], + String.format(context.getString(R.string.widget_posts_today), tag.uses)); + } else { + views.setViewVisibility(ITEM_IDS[i], View.GONE); + } + } + } + + manager.updateAppWidget(widgetId, views); + }); + } + + private static RemoteViews buildLoadingView(Context context) { + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_trending); + views.setTextViewText(R.id.tag_name_1, "Loading…"); + views.setViewVisibility(R.id.item_1, View.VISIBLE); + views.setViewVisibility(R.id.tag_count_1, View.GONE); + for (int i = 1; i < ITEM_IDS.length; i++) { + views.setViewVisibility(ITEM_IDS[i], View.GONE); + } + views.setViewVisibility(R.id.widget_empty, View.GONE); + return views; + } + + static String getServer(Context context) { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + return prefs.getString(PREF_SERVER, DEFAULT_SERVER); + } +} diff --git a/android/app/src/main/java/place/coho/app/WearSyncBridge.java b/android/app/src/main/java/place/coho/app/WearSyncBridge.java new file mode 100644 index 00000000..70e9a5ad --- /dev/null +++ b/android/app/src/main/java/place/coho/app/WearSyncBridge.java @@ -0,0 +1,64 @@ +package place.coho.app; + +import android.util.Log; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.google.android.gms.tasks.Tasks; +import com.google.android.gms.wearable.DataClient; +import com.google.android.gms.wearable.PutDataMapRequest; +import com.google.android.gms.wearable.Wearable; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Capacitor plugin that pushes auth credentials to a paired Wear OS + * device via the Wearable Data Layer API. + * + * The watch-side {@code AuthSyncService} listens for DataItem changes + * at path {@code /coho/auth} and persists the token locally. + */ +@CapacitorPlugin(name = "WearSyncBridge") +public class WearSyncBridge extends Plugin { + + private static final String TAG = "WearSyncBridge"; + private static final String DATA_PATH = "/coho/auth"; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + /** + * Push current credentials to the paired watch. + * Expected params: server (String), accessToken (String), acct (String). + */ + @PluginMethod + public void syncCredentials(PluginCall call) { + String server = call.getString("server", ""); + String accessToken = call.getString("accessToken", ""); + String acct = call.getString("acct", ""); + + executor.execute(() -> { + try { + DataClient dataClient = Wearable.getDataClient(getContext()); + PutDataMapRequest putReq = PutDataMapRequest.create(DATA_PATH); + putReq.getDataMap().putString("server", server); + putReq.getDataMap().putString("accessToken", accessToken); + putReq.getDataMap().putString("acct", acct); + // Force update even if data hasn't changed (timestamp trick) + putReq.getDataMap().putLong("timestamp", System.currentTimeMillis()); + putReq.setUrgent(); + + Tasks.await(dataClient.putDataItem(putReq.asPutDataRequest())); + Log.d(TAG, "Credentials synced to Wear OS for " + acct); + + getActivity().runOnUiThread(() -> call.resolve()); + } catch (Exception e) { + Log.w(TAG, "Failed to sync credentials to Wear OS", e); + // Resolve anyway — watch sync is best-effort and should not block the phone app + getActivity().runOnUiThread(() -> call.resolve()); + } + }); + } +} diff --git a/android/app/src/main/java/place/coho/app/WidgetBridge.java b/android/app/src/main/java/place/coho/app/WidgetBridge.java new file mode 100644 index 00000000..24434309 --- /dev/null +++ b/android/app/src/main/java/place/coho/app/WidgetBridge.java @@ -0,0 +1,51 @@ +package place.coho.app; + +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.SharedPreferences; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +/** + * Simple Capacitor plugin that bridges the web app's server URL + * to SharedPreferences so the Android widget can read it. + */ +@CapacitorPlugin(name = "WidgetBridge") +public class WidgetBridge extends Plugin { + + @PluginMethod + public void setServer(PluginCall call) { + String server = call.getString("server", TrendingWidget.DEFAULT_SERVER); + SharedPreferences prefs = getContext() + .getSharedPreferences(TrendingWidget.PREFS_NAME, Context.MODE_PRIVATE); + prefs.edit().putString(TrendingWidget.PREF_SERVER, server).apply(); + + // Trigger widget refresh so it picks up the new server + refreshWidgets(); + + call.resolve(); + } + + @PluginMethod + public void refresh(PluginCall call) { + refreshWidgets(); + call.resolve(); + } + + private void refreshWidgets() { + Context context = getContext(); + AppWidgetManager manager = AppWidgetManager.getInstance(context); + int[] ids = manager.getAppWidgetIds( + new ComponentName(context, TrendingWidget.class)); + if (ids != null && ids.length > 0) { + for (int id : ids) { + TrendingWidget.updateWidget(context, manager, id); + } + } + } +} diff --git a/android/app/src/main/res/drawable-hdpi/ic_action_name.png b/android/app/src/main/res/drawable-hdpi/ic_action_name.png new file mode 100644 index 00000000..427eaa60 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_action_name.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_stat_name.png b/android/app/src/main/res/drawable-hdpi/ic_stat_name.png new file mode 100644 index 00000000..f862f62e Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_stat_name.png differ 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 00000000..e31573b4 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 00000000..f7a64923 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 00000000..80772550 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 00000000..14c6c8fe 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 00000000..244ca250 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-mdpi/ic_action_name.png b/android/app/src/main/res/drawable-mdpi/ic_action_name.png new file mode 100644 index 00000000..ec50c126 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_action_name.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_stat_name.png b/android/app/src/main/res/drawable-mdpi/ic_stat_name.png new file mode 100644 index 00000000..81240a56 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_stat_name.png differ diff --git a/android/app/src/main/res/drawable-night/widget_background.xml b/android/app/src/main/res/drawable-night/widget_background.xml new file mode 100644 index 00000000..68da9707 --- /dev/null +++ b/android/app/src/main/res/drawable-night/widget_background.xml @@ -0,0 +1,6 @@ + + + + + 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 00000000..74faaa58 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 00000000..e944f4ad 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 00000000..564a82ff 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 00000000..bfabe687 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 00000000..69290712 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 00000000..c7bd21db --- /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-xhdpi/ic_action_name.png b/android/app/src/main/res/drawable-xhdpi/ic_action_name.png new file mode 100644 index 00000000..f534d4b5 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_action_name.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_stat_name.png b/android/app/src/main/res/drawable-xhdpi/ic_stat_name.png new file mode 100644 index 00000000..c354befd Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_stat_name.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_action_name.png b/android/app/src/main/res/drawable-xxhdpi/ic_action_name.png new file mode 100644 index 00000000..0a737ae1 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_action_name.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_stat_name.png b/android/app/src/main/res/drawable-xxhdpi/ic_stat_name.png new file mode 100644 index 00000000..16cc629b Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_stat_name.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_action_name.png b/android/app/src/main/res/drawable-xxxhdpi/ic_action_name.png new file mode 100644 index 00000000..920e1807 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_action_name.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png b/android/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png new file mode 100644 index 00000000..371ea56a Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png differ 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 00000000..d5fccc53 --- /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_shortcut_home.xml b/android/app/src/main/res/drawable/ic_shortcut_home.xml new file mode 100644 index 00000000..8f71e5df --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_home.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_shortcut_messages.xml b/android/app/src/main/res/drawable/ic_shortcut_messages.xml new file mode 100644 index 00000000..ef70ebb9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_messages.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_shortcut_new_post.xml b/android/app/src/main/res/drawable/ic_shortcut_new_post.xml new file mode 100644 index 00000000..d2a9a3ca --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_new_post.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_shortcut_notifications.xml b/android/app/src/main/res/drawable/ic_shortcut_notifications.xml new file mode 100644 index 00000000..7e29295a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_notifications.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_shortcut_search.xml b/android/app/src/main/res/drawable/ic_shortcut_search.xml new file mode 100644 index 00000000..63e08ceb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_search.xml @@ -0,0 +1,12 @@ + + + + + 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 00000000..f7a64923 Binary files /dev/null and b/android/app/src/main/res/drawable/splash.png differ diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 00000000..68da9707 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_item_background.xml b/android/app/src/main/res/drawable/widget_item_background.xml new file mode 100644 index 00000000..a38bc80b --- /dev/null +++ b/android/app/src/main/res/drawable/widget_item_background.xml @@ -0,0 +1,6 @@ + + + + + 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 00000000..b5ad1387 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/layout/widget_trending.xml b/android/app/src/main/res/layout/widget_trending.xml new file mode 100644 index 00000000..994eb8aa --- /dev/null +++ b/android/app/src/main/res/layout/widget_trending.xml @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 00000000..4df90c3c --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ 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 00000000..4df90c3c --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..90ce47cc Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..5db16075 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp new file mode 100644 index 00000000..f590f2b0 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..5245fda5 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..6b52f442 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..79b266c8 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp new file mode 100644 index 00000000..f8234fa2 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..65f0f453 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..9ae37ce9 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..90c0dabf Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp new file mode 100644 index 00000000..da547426 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1be4151c Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..2118bb48 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..40c2f1a7 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 00000000..1a4adf80 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..67b59bb8 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..a73ba6c0 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..3de3ee87 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 00000000..63908e3f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..6f110682 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values-night-v31/colors.xml b/android/app/src/main/res/values-night-v31/colors.xml new file mode 100644 index 00000000..c170ce9a --- /dev/null +++ b/android/app/src/main/res/values-night-v31/colors.xml @@ -0,0 +1,9 @@ + + + + @android:color/system_neutral1_800 + @android:color/system_accent1_200 + @android:color/system_neutral1_100 + @android:color/system_neutral2_200 + @android:color/system_neutral1_700 + diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 00000000..88b84228 --- /dev/null +++ b/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,8 @@ + + + #1C1B1F + #E6A3B5 + #E6E1E5 + #CAC4D0 + #2B2930 + diff --git a/android/app/src/main/res/values-v31/colors.xml b/android/app/src/main/res/values-v31/colors.xml new file mode 100644 index 00000000..3d8ddfb5 --- /dev/null +++ b/android/app/src/main/res/values-v31/colors.xml @@ -0,0 +1,9 @@ + + + + @android:color/system_neutral2_50 + @android:color/system_accent1_600 + @android:color/system_neutral1_900 + @android:color/system_neutral2_700 + @android:color/system_neutral1_10 + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..e2f4af10 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,15 @@ + + + + #d6325c + + + #F3EDF7 + #d6325c + #1C1B1F + #49454F + #FFFFFF + #d6325c + #a8284a + #d6325c + 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 00000000..c2490970 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #EFC4C4 + \ 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 00000000..55439930 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,24 @@ + + + Coho + Coho + place.coho.app + place.coho.app + Trending on Mastodon + Shows trending hashtags from Mastodon + Tap to open Coho + Coho Trending + %s posts today + + + Home + Go to home timeline + Explore + Explore trending content + Notifications + View your notifications + Messages + View your messages + New Post + Compose a new post + 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 00000000..be874e54 --- /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 00000000..bd0c4d80 --- /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/main/res/xml/shortcuts.xml b/android/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 00000000..8a6d098c --- /dev/null +++ b/android/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/trending_widget_info.xml b/android/app/src/main/res/xml/trending_widget_info.xml new file mode 100644 index 00000000..5a9f818b --- /dev/null +++ b/android/app/src/main/res/xml/trending_widget_info.xml @@ -0,0 +1,16 @@ + + 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 00000000..02973278 --- /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 00000000..b469df0b --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,31 @@ +// 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:9.1.0' + classpath 'com.google.gms:google-services:4.4.4' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0' + classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.1.0' + + // 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 00000000..3f70383b --- /dev/null +++ b/android/capacitor.settings.gradle @@ -0,0 +1,15 @@ +// 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-app' +project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') + +include ':capacitor-browser' +project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') + +include ':capacitor-push-notifications' +project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android') + +include ':capacitor-share' +project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android') diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..65681bf1 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,32 @@ +# 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 +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b 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 00000000..5f38436f --- /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-9.3.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 00000000..23d15a93 --- /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 00000000..5eed7ee8 --- /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 00000000..9f627b6f --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,9 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} +include ':app' +include ':wear' +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 00000000..0c479ae7 --- /dev/null +++ b/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 26 + 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/android/wear/build.gradle b/android/wear/build.gradle new file mode 100644 index 00000000..ec50b355 --- /dev/null +++ b/android/wear/build.gradle @@ -0,0 +1,72 @@ +apply plugin: 'com.android.application' +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' + +android { + namespace = "place.coho.app.wear" + compileSdk = rootProject.ext.compileSdkVersion + + defaultConfig { + applicationId "place.coho.app" + minSdkVersion 30 // Wear OS 3+ + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 7 + versionName "5.0" + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + compose true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } +} + +dependencies { + // Compose for Wear OS + def composeBom = platform('androidx.compose:compose-bom:2024.12.01') + implementation composeBom + + implementation 'androidx.wear.compose:compose-material3:1.5.6' + implementation 'androidx.wear.compose:compose-foundation:1.5.6' + implementation 'androidx.wear.compose:compose-navigation:1.5.6' + implementation 'androidx.compose.material3:material3:1.3.1' + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + + // Activity & Lifecycle + implementation 'androidx.activity:activity-compose:1.10.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.9.0' + + // Networking + implementation 'com.squareup.retrofit2:retrofit:2.11.0' + implementation 'com.squareup.retrofit2:converter-moshi:2.11.0' + implementation 'com.squareup.moshi:moshi-kotlin:1.15.2' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + + // Image loading + implementation 'io.coil-kt:coil-compose:2.7.0' + + // Wearable Data Layer (phone ↔ watch sync) + implementation 'com.google.android.gms:play-services-wearable:19.0.0' + + // DataStore for local credential persistence + implementation 'androidx.datastore:datastore-preferences:1.1.2' + + // Core AndroidX + implementation 'androidx.core:core-ktx:1.15.0' +} diff --git a/android/wear/proguard-rules.pro b/android/wear/proguard-rules.pro new file mode 100644 index 00000000..d71dd325 --- /dev/null +++ b/android/wear/proguard-rules.pro @@ -0,0 +1,5 @@ +# Default ProGuard rules for the Wear module +-keepattributes *Annotation* +-keep class place.coho.app.wear.api.models.** { *; } +-dontwarn okhttp3.** +-dontwarn retrofit2.** diff --git a/android/wear/src/main/AndroidManifest.xml b/android/wear/src/main/AndroidManifest.xml new file mode 100644 index 00000000..be440227 --- /dev/null +++ b/android/wear/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/wear/src/main/java/place/coho/app/wear/api/MastodonApi.kt b/android/wear/src/main/java/place/coho/app/wear/api/MastodonApi.kt new file mode 100644 index 00000000..5c9996bc --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/api/MastodonApi.kt @@ -0,0 +1,53 @@ +package place.coho.app.wear.api + +import place.coho.app.wear.api.models.Notification +import place.coho.app.wear.api.models.Status +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface MastodonApi { + + @GET("api/v1/timelines/home") + suspend fun getHomeTimeline( + @Query("limit") limit: Int = 12, + @Query("max_id") maxId: String? = null, + ): List + + @POST("api/v1/statuses/{id}/favourite") + suspend fun favouriteStatus(@Path("id") id: String): Status + + @POST("api/v1/statuses/{id}/unfavourite") + suspend fun unfavouriteStatus(@Path("id") id: String): Status + + @POST("api/v1/statuses/{id}/reblog") + suspend fun reblogStatus(@Path("id") id: String): Status + + @POST("api/v1/statuses/{id}/unreblog") + suspend fun unreblogStatus(@Path("id") id: String): Status + + @GET("api/v1/trends/statuses") + suspend fun getTrendingStatuses( + @Query("limit") limit: Int = 12, + ): List + + @GET("api/v1/statuses/{id}") + suspend fun getStatus(@Path("id") id: String): Status + + @GET("api/v1/notifications") + suspend fun getNotifications( + @Query("limit") limit: Int = 20, + @Query("max_id") maxId: String? = null, + ): List + + @FormUrlEncoded + @POST("api/v1/statuses") + suspend fun postStatus( + @Field("status") status: String, + @Field("visibility") visibility: String = "public", + @Field("in_reply_to_id") inReplyToId: String? = null, + ): Status +} diff --git a/android/wear/src/main/java/place/coho/app/wear/api/MastodonClient.kt b/android/wear/src/main/java/place/coho/app/wear/api/MastodonClient.kt new file mode 100644 index 00000000..80059c0f --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/api/MastodonClient.kt @@ -0,0 +1,64 @@ +package place.coho.app.wear.api + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.util.concurrent.TimeUnit + +/** + * Provides a configured [MastodonApi] instance. + * The base URL and auth token are set dynamically based on the synced credentials. + */ +object MastodonClient { + + private val moshi: Moshi = Moshi.Builder() + .addLast(KotlinJsonAdapterFactory()) + .build() + + /** + * Build an unauthenticated [MastodonApi] for public endpoints. + */ + fun createPublic(server: String = "mastodon.social"): MastodonApi { + val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl("https://$server/") + .client(client) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + return retrofit.create(MastodonApi::class.java) + } + + /** + * Build a [MastodonApi] for the given server and access token. + */ + fun create(server: String, accessToken: String): MastodonApi { + val authInterceptor = Interceptor { chain -> + val request = chain.request().newBuilder() + .addHeader("Authorization", "Bearer $accessToken") + .build() + chain.proceed(request) + } + + val client = OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl("https://$server/") + .client(client) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + return retrofit.create(MastodonApi::class.java) + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/api/models/Account.kt b/android/wear/src/main/java/place/coho/app/wear/api/models/Account.kt new file mode 100644 index 00000000..d38f5414 --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/api/models/Account.kt @@ -0,0 +1,14 @@ +package place.coho.app.wear.api.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +data class Account( + val id: String, + val username: String, + val acct: String, + @Json(name = "display_name") val displayName: String, + val avatar: String, + val bot: Boolean = false, +) diff --git a/android/wear/src/main/java/place/coho/app/wear/api/models/MediaAttachment.kt b/android/wear/src/main/java/place/coho/app/wear/api/models/MediaAttachment.kt new file mode 100644 index 00000000..c2cc5d5e --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/api/models/MediaAttachment.kt @@ -0,0 +1,13 @@ +package place.coho.app.wear.api.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +data class MediaAttachment( + val id: String, + val type: String, // "image", "gifv", "video", "audio" + val url: String, + @Json(name = "preview_url") val previewUrl: String?, + val description: String?, +) diff --git a/android/wear/src/main/java/place/coho/app/wear/api/models/Notification.kt b/android/wear/src/main/java/place/coho/app/wear/api/models/Notification.kt new file mode 100644 index 00000000..18ca8589 --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/api/models/Notification.kt @@ -0,0 +1,13 @@ +package place.coho.app.wear.api.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +data class Notification( + val id: String, + val type: String, // follow, mention, reblog, favourite, poll, status, update + @Json(name = "created_at") val createdAt: String, + val account: Account, + val status: Status?, +) diff --git a/android/wear/src/main/java/place/coho/app/wear/api/models/Status.kt b/android/wear/src/main/java/place/coho/app/wear/api/models/Status.kt new file mode 100644 index 00000000..a48cb3cf --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/api/models/Status.kt @@ -0,0 +1,23 @@ +package place.coho.app.wear.api.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +data class Status( + val id: String, + @Json(name = "created_at") val createdAt: String, + @Json(name = "in_reply_to_id") val inReplyToId: String?, + val sensitive: Boolean = false, + @Json(name = "spoiler_text") val spoilerText: String = "", + val visibility: String = "public", + val content: String, // HTML content + val reblog: Status?, + val account: Account, + @Json(name = "media_attachments") val mediaAttachments: List = emptyList(), + @Json(name = "replies_count") val repliesCount: Int = 0, + @Json(name = "reblogs_count") val reblogsCount: Int = 0, + @Json(name = "favourites_count") val favouritesCount: Int = 0, + val favourited: Boolean = false, + val reblogged: Boolean = false, +) diff --git a/android/wear/src/main/java/place/coho/app/wear/data/NotificationRepository.kt b/android/wear/src/main/java/place/coho/app/wear/data/NotificationRepository.kt new file mode 100644 index 00000000..ffd08698 --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/data/NotificationRepository.kt @@ -0,0 +1,26 @@ +package place.coho.app.wear.data + +import place.coho.app.wear.api.MastodonApi +import place.coho.app.wear.api.MastodonClient +import place.coho.app.wear.api.models.Notification +import place.coho.app.wear.sync.AuthState + +class NotificationRepository { + + private var api: MastodonApi? = null + private var currentServer: String? = null + private var currentToken: String? = null + + private fun getApi(auth: AuthState): MastodonApi { + if (api == null || currentServer != auth.server || currentToken != auth.accessToken) { + api = MastodonClient.create(auth.server, auth.accessToken) + currentServer = auth.server + currentToken = auth.accessToken + } + return api!! + } + + suspend fun getNotifications(auth: AuthState, maxId: String? = null): List { + return getApi(auth).getNotifications(limit = 20, maxId = maxId) + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/data/TimelineRepository.kt b/android/wear/src/main/java/place/coho/app/wear/data/TimelineRepository.kt new file mode 100644 index 00000000..ab04f936 --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/data/TimelineRepository.kt @@ -0,0 +1,67 @@ +package place.coho.app.wear.data + +import place.coho.app.wear.api.MastodonApi +import place.coho.app.wear.api.MastodonClient +import place.coho.app.wear.api.models.Status +import place.coho.app.wear.sync.AuthState + +class TimelineRepository { + + private var api: MastodonApi? = null + private var currentServer: String? = null + private var currentToken: String? = null + private var publicApi: MastodonApi? = null + + private fun getApi(auth: AuthState): MastodonApi { + if (api == null || currentServer != auth.server || currentToken != auth.accessToken) { + api = MastodonClient.create(auth.server, auth.accessToken) + currentServer = auth.server + currentToken = auth.accessToken + } + return api!! + } + + private fun getPublicApi(): MastodonApi { + if (publicApi == null) { + publicApi = MastodonClient.createPublic() + } + return publicApi!! + } + + suspend fun getHomeTimeline(auth: AuthState, maxId: String? = null): List { + return getApi(auth).getHomeTimeline(limit = 12, maxId = maxId) + } + + suspend fun getPublicTimeline(): List { + return getPublicApi().getTrendingStatuses(limit = 12) + } + + suspend fun getStatus(auth: AuthState, id: String): Status { + return getApi(auth).getStatus(id) + } + + suspend fun favouriteStatus(auth: AuthState, id: String): Status { + return getApi(auth).favouriteStatus(id) + } + + suspend fun unfavouriteStatus(auth: AuthState, id: String): Status { + return getApi(auth).unfavouriteStatus(id) + } + + suspend fun reblogStatus(auth: AuthState, id: String): Status { + return getApi(auth).reblogStatus(id) + } + + suspend fun unreblogStatus(auth: AuthState, id: String): Status { + return getApi(auth).unreblogStatus(id) + } + + suspend fun postStatus( + auth: AuthState, + status: String, + visibility: String = "public", + inReplyToId: String? = null, + ): Status { + return getApi(auth).postStatus(status, visibility, inReplyToId) + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/sync/AuthRepository.kt b/android/wear/src/main/java/place/coho/app/wear/sync/AuthRepository.kt new file mode 100644 index 00000000..fad858be --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/sync/AuthRepository.kt @@ -0,0 +1,50 @@ +package place.coho.app.wear.sync + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.authDataStore by preferencesDataStore(name = "coho_auth") + +data class AuthState( + val server: String, + val accessToken: String, + val acct: String, +) { + val isAuthenticated: Boolean get() = server.isNotBlank() && accessToken.isNotBlank() +} + +/** + * Single source of truth for watch-side credentials. + * Written by [AuthSyncService] when the phone pushes a DataItem, + * read by the UI layer to build API clients. + */ +class AuthRepository(private val context: Context) { + + private val keyServer = stringPreferencesKey("server") + private val keyAccessToken = stringPreferencesKey("access_token") + private val keyAcct = stringPreferencesKey("acct") + + val authState: Flow = context.authDataStore.data.map { prefs -> + AuthState( + server = prefs[keyServer] ?: "", + accessToken = prefs[keyAccessToken] ?: "", + acct = prefs[keyAcct] ?: "", + ) + } + + suspend fun saveCredentials(server: String, accessToken: String, acct: String) { + context.authDataStore.edit { prefs -> + prefs[keyServer] = server + prefs[keyAccessToken] = accessToken + prefs[keyAcct] = acct + } + } + + suspend fun clear() { + context.authDataStore.edit { it.clear() } + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/sync/AuthSyncService.kt b/android/wear/src/main/java/place/coho/app/wear/sync/AuthSyncService.kt new file mode 100644 index 00000000..527f8432 --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/sync/AuthSyncService.kt @@ -0,0 +1,42 @@ +package place.coho.app.wear.sync + +import com.google.android.gms.wearable.DataEventBuffer +import com.google.android.gms.wearable.DataMapItem +import com.google.android.gms.wearable.WearableListenerService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Receives DataItem updates pushed from the phone app via the Wearable Data Layer. + * When the phone stores or updates credentials at `/coho/auth`, this service + * persists them into local DataStore so the watch can call the Mastodon API. + */ +class AuthSyncService : WearableListenerService() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onDataChanged(dataEvents: DataEventBuffer) { + val repo = AuthRepository(applicationContext) + for (event in dataEvents) { + val path = event.dataItem.uri.path ?: continue + if (path == "/coho/auth") { + val dataMap = DataMapItem.fromDataItem(event.dataItem).dataMap + val server = dataMap.getString("server") ?: continue + val accessToken = dataMap.getString("accessToken") ?: continue + val acct = dataMap.getString("acct") ?: "" + + scope.launch { + repo.saveCredentials(server, accessToken, acct) + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + scope.cancel() + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/WearActivity.kt b/android/wear/src/main/java/place/coho/app/wear/ui/WearActivity.kt new file mode 100644 index 00000000..64e3c9be --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/WearActivity.kt @@ -0,0 +1,18 @@ +package place.coho.app.wear.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import place.coho.app.wear.sync.AuthRepository + +class WearActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val authRepository = AuthRepository(applicationContext) + + setContent { + WearApp(authRepository) + } + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/WearApp.kt b/android/wear/src/main/java/place/coho/app/wear/ui/WearApp.kt new file mode 100644 index 00000000..cef51ff5 --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/WearApp.kt @@ -0,0 +1,193 @@ +package place.coho.app.wear.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.navigation.SwipeDismissableNavHost +import androidx.wear.compose.navigation.composable +import androidx.wear.compose.navigation.rememberSwipeDismissableNavController +import kotlinx.coroutines.launch +import place.coho.app.wear.api.models.Status +import place.coho.app.wear.sync.AuthRepository +import place.coho.app.wear.sync.AuthState +import place.coho.app.wear.ui.compose.ComposeScreen +import place.coho.app.wear.ui.notifications.NotificationsScreen +import place.coho.app.wear.ui.settings.WatchSettingsScreen +import place.coho.app.wear.ui.theme.CohoWearTheme +import place.coho.app.wear.ui.timeline.PostDetailScreen +import place.coho.app.wear.ui.timeline.TimelineScreen +import place.coho.app.wear.ui.timeline.TimelineViewModel + +@Composable +fun WearApp(authRepository: AuthRepository) { + val authState by authRepository.authState.collectAsState( + initial = AuthState("", "", "") + ) + + CohoWearTheme { + val navController = rememberSwipeDismissableNavController() + var selectedStatus by remember { mutableStateOf(null) } + var replyToId by remember { mutableStateOf(null) } + var replyToAuthor by remember { mutableStateOf(null) } + val timelineViewModel: TimelineViewModel = viewModel() + + AppScaffold { + SwipeDismissableNavHost( + navController = navController, + startDestination = "home", + ) { + composable("home") { + if (authState.isAuthenticated) { + val pagerState = rememberPagerState(pageCount = { 3 }) + val coroutineScope = rememberCoroutineScope() + + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + when (page) { + 0 -> TimelineScreen( + auth = authState, + onNavigateToNotifications = { + coroutineScope.launch { + pagerState.animateScrollToPage(1) + } + }, + onPostClick = { status -> + selectedStatus = status + navController.navigate("postDetail") + }, + onCompose = { + replyToId = null + replyToAuthor = null + navController.navigate("compose") + }, + viewModel = timelineViewModel, + ) + 1 -> NotificationsScreen( + auth = authState, + onPostClick = { status -> + selectedStatus = status + navController.navigate("postDetail") + }, + ) + 2 -> WatchSettingsScreen( + authRepository = authRepository, + onLoggedOut = {}, + ) + } + } + + PageIndicator( + pageCount = 3, + currentPage = pagerState.currentPage, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 2.dp), + ) + } + } else { + TimelineScreen( + auth = null, + onNavigateToNotifications = null, + onPostClick = { status -> + selectedStatus = status + navController.navigate("postDetail") + }, + viewModel = timelineViewModel, + ) + } + } + + composable("postDetail") { + val status = selectedStatus + if (status != null) { + PostDetailScreen( + status = status, + auth = if (timelineViewModel.isAuthenticated) authState else null, + onFavourite = if (timelineViewModel.isAuthenticated) { + { timelineViewModel.toggleFavourite(it) } + } else null, + onBoost = if (timelineViewModel.isAuthenticated) { + { timelineViewModel.toggleBoost(it) } + } else null, + onReply = if (timelineViewModel.isAuthenticated) { + { id, author -> + replyToId = id + replyToAuthor = author + navController.navigate("compose") + } + } else null, + ) + } + } + + composable("compose") { + ComposeScreen( + auth = authState, + replyToId = replyToId, + replyToAuthor = replyToAuthor, + onDismiss = { + replyToId = null + replyToAuthor = null + navController.popBackStack() + }, + onPosted = { + replyToId = null + replyToAuthor = null + navController.popBackStack() + timelineViewModel.refresh() + }, + ) + } + } + } + } +} + +@Composable +private fun PageIndicator( + pageCount: Int, + currentPage: Int, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = modifier, + ) { + repeat(pageCount) { index -> + Box( + modifier = Modifier + .padding(horizontal = 3.dp) + .size(if (index == currentPage) 8.dp else 6.dp) + .clip(CircleShape) + .background( + if (index == currentPage) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ), + ) + } + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/components/WearPostContent.kt b/android/wear/src/main/java/place/coho/app/wear/ui/components/WearPostContent.kt new file mode 100644 index 00000000..e1ff37e4 --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/components/WearPostContent.kt @@ -0,0 +1,38 @@ +package place.coho.app.wear.ui.components + +import android.text.Html +import java.time.Duration +import java.time.Instant +import java.time.format.DateTimeParseException + +/** + * Strips HTML tags from Mastodon post content and returns plain text. + * Also converts `
` and `

` to newlines for readability. + */ +fun htmlToPlainText(html: String): String { + // Use Android's Html parser which handles entities and tags + return Html.fromHtml(html, Html.FROM_HTML_MODE_COMPACT) + .toString() + .trim() +} + +/** + * Returns a human-readable relative time string, e.g. "5m ago", "2h ago". + */ +fun relativeTime(isoTimestamp: String): String { + return try { + val then = Instant.parse(isoTimestamp) + val now = Instant.now() + val duration = Duration.between(then, now) + + when { + duration.toMinutes() < 1 -> "now" + duration.toHours() < 1 -> "${duration.toMinutes()}m" + duration.toDays() < 1 -> "${duration.toHours()}h" + duration.toDays() < 7 -> "${duration.toDays()}d" + else -> "${duration.toDays() / 7}w" + } + } catch (_: DateTimeParseException) { + "" + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/compose/ComposeScreen.kt b/android/wear/src/main/java/place/coho/app/wear/ui/compose/ComposeScreen.kt new file mode 100644 index 00000000..2e0f6ab0 --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/compose/ComposeScreen.kt @@ -0,0 +1,202 @@ +package place.coho.app.wear.ui.compose + +import android.app.Activity +import android.content.Intent +import android.speech.RecognizerIntent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.CircularProgressIndicator +import androidx.wear.compose.material3.FilledTonalButton +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import place.coho.app.wear.R +import place.coho.app.wear.sync.AuthState + +@Composable +fun ComposeScreen( + auth: AuthState, + replyToId: String? = null, + replyToAuthor: String? = null, + onDismiss: () -> Unit, + onPosted: () -> Unit, + viewModel: ComposeViewModel = viewModel(), +) { + val composeState by viewModel.uiState.collectAsState() + var voiceText by remember { mutableStateOf("") } + var hasLaunched by remember { mutableStateOf(false) } + + val voiceIntent = remember { + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, + ) + putExtra(RecognizerIntent.EXTRA_PROMPT, "What's on your mind?") + } + } + + val voiceLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val results = result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + voiceText = results?.firstOrNull() ?: "" + } + } + + // Auto-launch voice recognizer on first composition + LaunchedEffect(Unit) { + if (!hasLaunched) { + hasLaunched = true + voiceLauncher.launch(voiceIntent) + } + } + + // Navigate back on successful post + LaunchedEffect(composeState) { + if (composeState is ComposeUiState.Sent) { + viewModel.resetState() + onPosted() + } + } + + val listState = rememberScalingLazyListState() + val config = LocalConfiguration.current + val horizontalPadding = (config.screenWidthDp * 0.052f).dp + val verticalPadding = if (config.isScreenRound) (config.screenHeightDp * 0.22f).dp else 24.dp + val columnPadding = PaddingValues( + horizontal = horizontalPadding, + vertical = verticalPadding, + ) + + ScreenScaffold( + scrollState = listState, + ) { + when (composeState) { + is ComposeUiState.Sending -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + CircularProgressIndicator() + } + } + else -> { + ScalingLazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = columnPadding, + modifier = Modifier.fillMaxSize(), + ) { + item { + ListHeader { + Text(stringResource(R.string.compose_title)) + } + } + + // Reply context + if (replyToAuthor != null) { + item { + Text( + text = stringResource(R.string.replying_to, "@$replyToAuthor"), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + ) + } + } + + // Voice text display + item { + Text( + text = voiceText.ifBlank { + stringResource(R.string.voice_no_speech) + }, + style = MaterialTheme.typography.bodySmall, + lineHeight = 18.sp, + textAlign = TextAlign.Center, + color = if (voiceText.isBlank()) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } + + // Error message + if (composeState is ComposeUiState.Error) { + item { + Text( + text = (composeState as ComposeUiState.Error).message, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + // Action buttons + if (voiceText.isNotBlank()) { + item { + FilledTonalButton( + onClick = { voiceLauncher.launch(voiceIntent) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.voice_retry)) + } + } + + item { + FilledTonalButton( + onClick = { viewModel.postStatus(auth, voiceText, inReplyToId = replyToId) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.compose_post)) + } + } + } else { + item { + FilledTonalButton( + onClick = { voiceLauncher.launch(voiceIntent) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.voice_retry)) + } + } + } + } + } + } + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/compose/ComposeViewModel.kt b/android/wear/src/main/java/place/coho/app/wear/ui/compose/ComposeViewModel.kt new file mode 100644 index 00000000..345d3e86 --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/compose/ComposeViewModel.kt @@ -0,0 +1,43 @@ +package place.coho.app.wear.ui.compose + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import place.coho.app.wear.data.TimelineRepository +import place.coho.app.wear.sync.AuthState + +sealed interface ComposeUiState { + data object Idle : ComposeUiState + data object Sending : ComposeUiState + data object Sent : ComposeUiState + data class Error(val message: String) : ComposeUiState +} + +class ComposeViewModel( + private val repository: TimelineRepository = TimelineRepository(), +) : ViewModel() { + + private val _uiState = MutableStateFlow(ComposeUiState.Idle) + val uiState: StateFlow = _uiState + + fun postStatus(auth: AuthState, text: String, visibility: String = "public", inReplyToId: String? = null) { + if (text.isBlank()) return + viewModelScope.launch { + _uiState.value = ComposeUiState.Sending + try { + repository.postStatus(auth, text, visibility, inReplyToId) + _uiState.value = ComposeUiState.Sent + } catch (e: Exception) { + _uiState.value = ComposeUiState.Error( + e.message ?: "Failed to post" + ) + } + } + } + + fun resetState() { + _uiState.value = ComposeUiState.Idle + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/notifications/NotificationsScreen.kt b/android/wear/src/main/java/place/coho/app/wear/ui/notifications/NotificationsScreen.kt new file mode 100644 index 00000000..a38a1eea --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/notifications/NotificationsScreen.kt @@ -0,0 +1,246 @@ +package place.coho.app.wear.ui.notifications + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Card +import androidx.wear.compose.material3.CircularProgressIndicator +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import coil.compose.AsyncImage +import place.coho.app.wear.R +import place.coho.app.wear.api.models.Notification +import place.coho.app.wear.api.models.Status +import place.coho.app.wear.ui.components.htmlToPlainText +import place.coho.app.wear.ui.components.relativeTime +import place.coho.app.wear.sync.AuthState +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationsScreen( + auth: AuthState, + onPostClick: ((Status) -> Unit)? = null, + viewModel: NotificationsViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + val isRefreshing by viewModel.isRefreshing.collectAsState() + + LaunchedEffect(auth) { + viewModel.loadNotifications(auth) + } + + when (val state = uiState) { + is NotificationsUiState.Loading -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + CircularProgressIndicator() + } + } + + is NotificationsUiState.Error -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize().padding(16.dp), + ) { + Text( + text = stringResource(R.string.error_no_connection), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + is NotificationsUiState.Success -> { + val listState = rememberScalingLazyListState() + val config = androidx.compose.ui.platform.LocalConfiguration.current + val horizontalPadding = (config.screenWidthDp * 0.052f).dp + val verticalPadding = if (config.isScreenRound) (config.screenHeightDp * 0.22f).dp else 24.dp + val columnPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = horizontalPadding, + vertical = verticalPadding, + ) + + // Trigger load-more when near the end of the list + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + lastVisibleIndex >= totalItems - 3 && totalItems > 0 + } + } + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore && state.canLoadMore && !state.isLoadingMore) { + viewModel.loadMore() + } + } + + ScreenScaffold( + scrollState = listState, + ) { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { viewModel.refresh() }, + modifier = Modifier.fillMaxSize(), + ) { + ScalingLazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = columnPadding, + modifier = Modifier.fillMaxSize(), + ) { + item { + ListHeader { + Text(stringResource(R.string.notifications_title)) + } + } + + items(state.notifications, key = { it.id }) { notification -> + NotificationCard( + notification = notification, + onPostClick = onPostClick, + ) + } + + // Loading more indicator + if (state.isLoadingMore) { + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().padding(8.dp), + ) { + CircularProgressIndicator() + } + } + } + } + } + } + } + } +} + +@Composable +private fun NotificationCard( + notification: Notification, + onPostClick: ((Status) -> Unit)? = null, +) { + Card( + onClick = { + notification.status?.let { status -> onPostClick?.invoke(status) } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = notificationIcon(notification.type), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.width(6.dp)) + AsyncImage( + model = notification.account.avatar, + contentDescription = null, + modifier = Modifier + .size(18.dp) + .clip(CircleShape), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = notification.account.displayName.ifBlank { + notification.account.username + }, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = relativeTime(notification.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = notificationLabel(notification.type), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Show post preview if available + notification.status?.let { status -> + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = htmlToPlainText(status.content), + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + } + } +} + +private fun notificationIcon(type: String): String = when (type) { + "follow" -> "👤" + "follow_request" -> "👤" + "mention" -> "💬" + "reblog" -> "🔁" + "favourite" -> "❤️" + "poll" -> "📊" + "status" -> "📝" + "update" -> "✏️" + else -> "🔔" +} + +private fun notificationLabel(type: String): String = when (type) { + "follow" -> "followed you" + "follow_request" -> "requested to follow you" + "mention" -> "mentioned you" + "reblog" -> "boosted your post" + "favourite" -> "favorited your post" + "poll" -> "poll ended" + "status" -> "posted" + "update" -> "edited a post" + else -> "" +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/notifications/NotificationsViewModel.kt b/android/wear/src/main/java/place/coho/app/wear/ui/notifications/NotificationsViewModel.kt new file mode 100644 index 00000000..e6e1e2f6 --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/notifications/NotificationsViewModel.kt @@ -0,0 +1,87 @@ +package place.coho.app.wear.ui.notifications + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import place.coho.app.wear.api.models.Notification +import place.coho.app.wear.data.NotificationRepository +import place.coho.app.wear.sync.AuthState + +sealed interface NotificationsUiState { + data object Loading : NotificationsUiState + data class Success( + val notifications: List, + val isLoadingMore: Boolean = false, + val canLoadMore: Boolean = true, + ) : NotificationsUiState + data class Error(val message: String) : NotificationsUiState +} + +class NotificationsViewModel( + private val repository: NotificationRepository = NotificationRepository(), +) : ViewModel() { + + private val _uiState = MutableStateFlow(NotificationsUiState.Loading) + val uiState: StateFlow = _uiState + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing + + private var currentAuth: AuthState? = null + + fun loadNotifications(auth: AuthState) { + currentAuth = auth + viewModelScope.launch { + _uiState.value = NotificationsUiState.Loading + try { + val notifications = repository.getNotifications(auth) + _uiState.value = NotificationsUiState.Success(notifications) + } catch (e: Exception) { + _uiState.value = NotificationsUiState.Error( + e.message ?: "Failed to load notifications" + ) + } + } + } + + fun refresh() { + val auth = currentAuth ?: return + viewModelScope.launch { + _isRefreshing.value = true + try { + val notifications = repository.getNotifications(auth) + _uiState.value = NotificationsUiState.Success(notifications) + } catch (_: Exception) { + // Keep existing content on refresh failure + } finally { + _isRefreshing.value = false + } + } + } + + fun loadMore() { + val auth = currentAuth ?: return + val currentState = _uiState.value as? NotificationsUiState.Success ?: return + if (currentState.isLoadingMore || !currentState.canLoadMore) return + val lastId = currentState.notifications.lastOrNull()?.id ?: return + + _uiState.value = currentState.copy(isLoadingMore = true) + + viewModelScope.launch { + try { + val olderNotifications = repository.getNotifications(auth, maxId = lastId) + val current = (_uiState.value as? NotificationsUiState.Success) ?: return@launch + _uiState.value = current.copy( + notifications = current.notifications + olderNotifications, + isLoadingMore = false, + canLoadMore = olderNotifications.isNotEmpty(), + ) + } catch (_: Exception) { + val current = (_uiState.value as? NotificationsUiState.Success) ?: return@launch + _uiState.value = current.copy(isLoadingMore = false) + } + } + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/settings/WatchSettingsScreen.kt b/android/wear/src/main/java/place/coho/app/wear/ui/settings/WatchSettingsScreen.kt new file mode 100644 index 00000000..b7559561 --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/settings/WatchSettingsScreen.kt @@ -0,0 +1,86 @@ +package place.coho.app.wear.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.FilledTonalButton +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import kotlinx.coroutines.launch +import place.coho.app.wear.R +import place.coho.app.wear.sync.AuthRepository + +@Composable +fun WatchSettingsScreen( + authRepository: AuthRepository, + onLoggedOut: () -> Unit, +) { + val listState = rememberScalingLazyListState() + val config = LocalConfiguration.current + val horizontalPadding = (config.screenWidthDp * 0.052f).dp + val verticalPadding = if (config.isScreenRound) (config.screenHeightDp * 0.22f).dp else 24.dp + val columnPadding = PaddingValues( + horizontal = horizontalPadding, + vertical = verticalPadding, + ) + val scope = rememberCoroutineScope() + var confirmingLogout by remember { mutableStateOf(false) } + + ScreenScaffold( + scrollState = listState, + ) { + ScalingLazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = columnPadding, + modifier = Modifier.fillMaxSize(), + ) { + item { + ListHeader { + Text(stringResource(R.string.settings_title)) + } + } + + item { + if (confirmingLogout) { + FilledTonalButton( + onClick = { + scope.launch { + authRepository.clear() + onLoggedOut() + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.logout_confirm), + color = MaterialTheme.colorScheme.error, + ) + } + } else { + FilledTonalButton( + onClick = { confirmingLogout = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.logout_button)) + } + } + } + } + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/theme/CohoWearTheme.kt b/android/wear/src/main/java/place/coho/app/wear/ui/theme/CohoWearTheme.kt new file mode 100644 index 00000000..ddabba8a --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/theme/CohoWearTheme.kt @@ -0,0 +1,41 @@ +package place.coho.app.wear.ui.theme + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.wear.compose.material3.ColorScheme +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.dynamicColorScheme + +// Coho brand colors — used as fallback when dynamic colors are unavailable +val CohoPink = Color(0xFFD6325C) +val CohoPinkLight = Color(0xFFE6A3B5) +val CohoPinkDark = Color(0xFFA82048) + +private val FallbackColorScheme = ColorScheme( + primary = CohoPink, + onPrimary = Color.White, + primaryContainer = CohoPinkDark, + onPrimaryContainer = CohoPinkLight, + secondary = CohoPinkLight, + onSecondary = Color.Black, + onSurface = Color(0xFFE6E1E5), + onSurfaceVariant = Color(0xFFCAC4D0), + error = Color(0xFFF2B8B5), + onError = Color(0xFF601410), +) + +@Composable +fun CohoWearTheme(content: @Composable () -> Unit) { + val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + dynamicColorScheme(LocalContext.current) ?: FallbackColorScheme + } else { + FallbackColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + content = content, + ) +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/timeline/PostCard.kt b/android/wear/src/main/java/place/coho/app/wear/ui/timeline/PostCard.kt new file mode 100644 index 00000000..3482db5e --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/timeline/PostCard.kt @@ -0,0 +1,198 @@ +package place.coho.app.wear.ui.timeline + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material3.Card +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import coil.compose.AsyncImage +import place.coho.app.wear.api.models.Status +import place.coho.app.wear.ui.components.htmlToPlainText +import place.coho.app.wear.ui.components.relativeTime + +@Composable +fun PostCard( + status: Status, + onFavourite: ((String) -> Unit)?, + onBoost: ((String) -> Unit)?, + onClick: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + // Show the actual content post (unwrap boosts) + val displayStatus = status.reblog ?: status + val isBoosted = status.reblog != null + + Card( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(4.dp)) { + // Boost indicator + if (isBoosted) { + Text( + text = "\uD83D\uDD01 ${status.account.displayName} boosted", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + } + + // Author row: avatar + name + time + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + AsyncImage( + model = displayStatus.account.avatar, + contentDescription = null, + modifier = Modifier + .size(20.dp) + .clip(CircleShape), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = displayStatus.account.displayName.ifBlank { displayStatus.account.username }, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = relativeTime(displayStatus.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Content warning + if (displayStatus.spoilerText.isNotBlank()) { + Text( + text = "⚠️ ${displayStatus.spoilerText}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + // Post content (HTML → plain text, truncated) + Text( + text = htmlToPlainText(displayStatus.content), + style = MaterialTheme.typography.bodySmall, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + lineHeight = 16.sp, + ) + } + + // Media indicator + if (displayStatus.mediaAttachments.isNotEmpty()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "📎 ${displayStatus.mediaAttachments.size} attachment(s)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Quick actions row (only when authenticated) + if (onFavourite != null || onBoost != null) { + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + // Favourite button + if (onFavourite != null) { + ActionChip( + label = if (displayStatus.favourited) "❤\uFE0F" else "\uD83E\uDD0D", + count = displayStatus.favouritesCount, + onClick = { onFavourite(displayStatus.id) }, + ) + } + // Boost button + if (onBoost != null) { + ActionChip( + label = if (displayStatus.reblogged) "\uD83D\uDD01" else "\uD83D\uDD04", + count = displayStatus.reblogsCount, + onClick = { onBoost(displayStatus.id) }, + ) + } + // Reply count (read-only) + Text( + text = "\uD83D\uDCAC ${displayStatus.repliesCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + } else { + // Read-only stats row + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "❤\uFE0F ${displayStatus.favouritesCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "\uD83D\uDD01 ${displayStatus.reblogsCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "\uD83D\uDCAC ${displayStatus.repliesCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun ActionChip( + label: String, + count: Int, + onClick: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + androidx.wear.compose.material3.TextButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.Confirm) + onClick() + }, + ) { + Text( + text = "$label $count", + style = MaterialTheme.typography.labelSmall, + ) + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/timeline/PostDetailScreen.kt b/android/wear/src/main/java/place/coho/app/wear/ui/timeline/PostDetailScreen.kt new file mode 100644 index 00000000..ef49a0b7 --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/timeline/PostDetailScreen.kt @@ -0,0 +1,310 @@ +package place.coho.app.wear.ui.timeline + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Card +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import coil.compose.AsyncImage +import place.coho.app.wear.R +import place.coho.app.wear.api.models.Status +import place.coho.app.wear.data.TimelineRepository +import place.coho.app.wear.sync.AuthState +import place.coho.app.wear.ui.components.htmlToPlainText +import place.coho.app.wear.ui.components.relativeTime +import androidx.compose.ui.res.stringResource + +@Composable +fun PostDetailScreen( + status: Status, + auth: AuthState? = null, + onFavourite: ((String) -> Unit)?, + onBoost: ((String) -> Unit)?, + onReply: ((String, String) -> Unit)? = null, +) { + val displayStatus = status.reblog ?: status + val isBoosted = status.reblog != null + val listState = rememberScalingLazyListState() + + // Fetch parent post if this is a reply + var parentStatus by remember { mutableStateOf(null) } + if (displayStatus.inReplyToId != null && auth != null) { + LaunchedEffect(displayStatus.inReplyToId) { + try { + val repo = TimelineRepository() + parentStatus = repo.getStatus(auth, displayStatus.inReplyToId) + } catch (_: Exception) { + // Parent fetch is best-effort + } + } + } + + val config = LocalConfiguration.current + val horizontalPadding = (config.screenWidthDp * 0.052f).dp + val verticalPadding = if (config.isScreenRound) (config.screenHeightDp * 0.22f).dp else 24.dp + val columnPadding = PaddingValues( + horizontal = horizontalPadding, + vertical = verticalPadding, + ) + + ScreenScaffold( + scrollState = listState, + ) { + ScalingLazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = columnPadding, + modifier = Modifier.fillMaxSize(), + ) { + // Parent post context (if this is a reply) + val parent = parentStatus + if (parent != null) { + item { + Card( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(4.dp)) { + Text( + text = stringResource( + R.string.in_reply_to, + parent.account.displayName.ifBlank { parent.account.username }, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = htmlToPlainText(parent.content), + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Boost indicator + if (isBoosted) { + item { + Text( + text = "\uD83D\uDD01 ${status.account.displayName} boosted", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + // Author info + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth(), + ) { + AsyncImage( + model = displayStatus.account.avatar, + contentDescription = null, + modifier = Modifier + .size(28.dp) + .clip(CircleShape), + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayStatus.account.displayName.ifBlank { + displayStatus.account.username + }, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "@${displayStatus.account.acct}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + // Content warning + if (displayStatus.spoilerText.isNotBlank()) { + item { + Text( + text = "⚠\uFE0F ${displayStatus.spoilerText}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + + // Full post content (no truncation) + item { + Text( + text = htmlToPlainText(displayStatus.content), + style = MaterialTheme.typography.bodySmall, + lineHeight = 18.sp, + ) + } + + // Media attachments + if (displayStatus.mediaAttachments.isNotEmpty()) { + items(displayStatus.mediaAttachments.size) { index -> + val attachment = displayStatus.mediaAttachments[index] + val imageUrl = attachment.previewUrl ?: attachment.url + + if (attachment.type == "image" || attachment.type == "gifv") { + Card( + onClick = { }, + modifier = Modifier + .fillMaxWidth(), + ) { + AsyncImage( + model = imageUrl, + contentDescription = attachment.description, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(8.dp)), + ) + } + } else { + Text( + text = "\uD83C\uDFA5 ${attachment.type} attachment", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Timestamp + item { + Text( + text = relativeTime(displayStatus.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Stats & actions + item { + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth(), + ) { + if (onFavourite != null) { + ActionChip( + label = if (displayStatus.favourited) "❤\uFE0F" else "\uD83E\uDD0D", + count = displayStatus.favouritesCount, + onClick = { onFavourite(displayStatus.id) }, + ) + } else { + Text( + text = "❤\uFE0F ${displayStatus.favouritesCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (onBoost != null) { + ActionChip( + label = if (displayStatus.reblogged) "\uD83D\uDD01" else "\uD83D\uDD04", + count = displayStatus.reblogsCount, + onClick = { onBoost(displayStatus.id) }, + ) + } else { + Text( + text = "\uD83D\uDD01 ${displayStatus.reblogsCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (onReply != null) { + ActionChip( + label = "💬", + count = displayStatus.repliesCount, + onClick = { + onReply( + displayStatus.id, + displayStatus.account.acct, + ) + }, + ) + } else { + Text( + text = "💬 ${displayStatus.repliesCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + } + } + } + } +} + +@Composable +private fun ActionChip( + label: String, + count: Int, + onClick: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + androidx.wear.compose.material3.TextButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.Confirm) + onClick() + }, + ) { + Text( + text = "$label $count", + style = MaterialTheme.typography.labelSmall, + ) + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/timeline/TimelineScreen.kt b/android/wear/src/main/java/place/coho/app/wear/ui/timeline/TimelineScreen.kt new file mode 100644 index 00000000..d25dd5ae --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/timeline/TimelineScreen.kt @@ -0,0 +1,226 @@ +package place.coho.app.wear.ui.timeline + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.ui.platform.LocalConfiguration +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.Card +import androidx.wear.compose.material3.CircularProgressIndicator +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.TextButton +import place.coho.app.wear.R +import place.coho.app.wear.api.models.Status +import place.coho.app.wear.sync.AuthState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.ui.text.font.FontWeight + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimelineScreen( + auth: AuthState?, + onNavigateToNotifications: (() -> Unit)? = null, + onPostClick: ((Status) -> Unit)? = null, + onCompose: (() -> Unit)? = null, + viewModel: TimelineViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + val isRefreshing by viewModel.isRefreshing.collectAsState() + + LaunchedEffect(auth) { + viewModel.loadTimeline(auth) + } + + when (val state = uiState) { + is TimelineUiState.Loading -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + CircularProgressIndicator() + } + } + + is TimelineUiState.Error -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize().padding(16.dp), + ) { + Text( + text = stringResource(R.string.error_no_connection), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + is TimelineUiState.Success -> { + val listState = rememberScalingLazyListState() + val config = LocalConfiguration.current + val horizontalPadding = (config.screenWidthDp * 0.052f).dp + val verticalPadding = if (config.isScreenRound) (config.screenHeightDp * 0.22f).dp else 24.dp + val columnPadding = PaddingValues( + horizontal = horizontalPadding, + vertical = verticalPadding, + ) + + // Trigger load-more when near the end of the list + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + lastVisibleIndex >= totalItems - 3 && totalItems > 0 + } + } + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore && state.canLoadMore && !state.isLoadingMore) { + viewModel.loadMore() + } + } + + ScreenScaffold( + scrollState = listState, + ) { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { viewModel.refresh() }, + modifier = Modifier.fillMaxSize(), + ) { + ScalingLazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = columnPadding, + modifier = Modifier.fillMaxSize(), + ) { + item { + ListHeader { + Text( + if (auth?.isAuthenticated == true) + stringResource(R.string.timeline_title) + else + stringResource(R.string.trending_title) + ) + } + } + + // Sync prompt (unauthenticated only) + if (auth?.isAuthenticated != true) { + item { + Card( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(4.dp)) { + Text( + text = stringResource(R.string.sync_prompt_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.error_not_authenticated), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // New post button (authenticated only) + if (onCompose != null) { + item { + Button( + onClick = onCompose, + modifier = Modifier.fillMaxWidth(), + icon = { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + label = { Text(stringResource(R.string.voice_compose)) }, + ) + } + } + + // Spacer between button and posts + if (onCompose != null) { + item { + androidx.compose.foundation.layout.Spacer( + modifier = Modifier.padding(bottom = 4.dp), + ) + } + } + + items(state.posts, key = { it.id }) { status -> + PostCard( + status = status, + onFavourite = if (viewModel.isAuthenticated) { + { viewModel.toggleFavourite(it) } + } else null, + onBoost = if (viewModel.isAuthenticated) { + { viewModel.toggleBoost(it) } + } else null, + onClick = { onPostClick?.invoke(status) }, + ) + } + + // Loading more indicator + if (state.isLoadingMore) { + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().padding(8.dp), + ) { + CircularProgressIndicator() + } + } + } + + // Navigate to notifications (authenticated only) + if (onNavigateToNotifications != null) { + item { + TextButton( + onClick = onNavigateToNotifications, + modifier = Modifier.fillMaxWidth(), + ) { + Text("🔔 ${stringResource(R.string.notifications_title)}") + } + } + } + } + } + } + } + } +} diff --git a/android/wear/src/main/java/place/coho/app/wear/ui/timeline/TimelineViewModel.kt b/android/wear/src/main/java/place/coho/app/wear/ui/timeline/TimelineViewModel.kt new file mode 100644 index 00000000..d43f7b8c --- /dev/null +++ b/android/wear/src/main/java/place/coho/app/wear/ui/timeline/TimelineViewModel.kt @@ -0,0 +1,173 @@ +package place.coho.app.wear.ui.timeline + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import place.coho.app.wear.api.models.Status +import place.coho.app.wear.data.TimelineRepository +import place.coho.app.wear.sync.AuthState + +sealed interface TimelineUiState { + data object Loading : TimelineUiState + data class Success( + val posts: List, + val isLoadingMore: Boolean = false, + val canLoadMore: Boolean = true, + ) : TimelineUiState + data class Error(val message: String) : TimelineUiState +} + +class TimelineViewModel( + private val repository: TimelineRepository = TimelineRepository(), +) : ViewModel() { + + private val _uiState = MutableStateFlow(TimelineUiState.Loading) + val uiState: StateFlow = _uiState + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing + + private var currentAuth: AuthState? = null + val isAuthenticated: Boolean get() = currentAuth?.isAuthenticated == true + + fun loadTimeline(auth: AuthState?) { + currentAuth = auth + viewModelScope.launch { + _uiState.value = TimelineUiState.Loading + try { + val posts = if (auth != null && auth.isAuthenticated) { + repository.getHomeTimeline(auth) + } else { + repository.getPublicTimeline() + } + _uiState.value = TimelineUiState.Success(posts) + } catch (e: Exception) { + _uiState.value = TimelineUiState.Error( + e.message ?: "Failed to load timeline" + ) + } + } + } + + fun refresh() { + val auth = currentAuth + viewModelScope.launch { + _isRefreshing.value = true + try { + val posts = if (auth != null && auth.isAuthenticated) { + repository.getHomeTimeline(auth) + } else { + repository.getPublicTimeline() + } + _uiState.value = TimelineUiState.Success(posts) + } catch (_: Exception) { + // Keep existing content on refresh failure + } finally { + _isRefreshing.value = false + } + } + } + + fun loadMore() { + val auth = currentAuth + val currentState = _uiState.value as? TimelineUiState.Success ?: return + if (currentState.isLoadingMore || !currentState.canLoadMore) return + val lastId = currentState.posts.lastOrNull()?.id ?: return + + _uiState.value = currentState.copy(isLoadingMore = true) + + viewModelScope.launch { + try { + val olderPosts = if (auth != null && auth.isAuthenticated) { + repository.getHomeTimeline(auth, maxId = lastId) + } else { + emptyList() + } + val current = (_uiState.value as? TimelineUiState.Success) ?: return@launch + _uiState.value = current.copy( + posts = current.posts + olderPosts, + isLoadingMore = false, + canLoadMore = olderPosts.isNotEmpty(), + ) + } catch (_: Exception) { + val current = (_uiState.value as? TimelineUiState.Success) ?: return@launch + _uiState.value = current.copy(isLoadingMore = false) + } + } + } + + fun toggleFavourite(statusId: String) { + val auth = currentAuth ?: return + val currentState = _uiState.value as? TimelineUiState.Success ?: return + + // Optimistic update + val updatedPosts = currentState.posts.map { status -> + val target = status.reblog ?: status + if (target.id == statusId) { + val updated = target.copy( + favourited = !target.favourited, + favouritesCount = target.favouritesCount + if (target.favourited) -1 else 1, + ) + if (status.reblog != null) status.copy(reblog = updated) else updated + } else { + status + } + } + _uiState.value = TimelineUiState.Success(updatedPosts) + + // Fire API call + viewModelScope.launch { + try { + val target = currentState.posts.firstNotNullOfOrNull { s -> + val t = s.reblog ?: s; if (t.id == statusId) t else null + } ?: return@launch + if (target.favourited) { + repository.unfavouriteStatus(auth, statusId) + } else { + repository.favouriteStatus(auth, statusId) + } + } catch (_: Exception) { + // Revert on failure + refresh() + } + } + } + + fun toggleBoost(statusId: String) { + val auth = currentAuth ?: return + val currentState = _uiState.value as? TimelineUiState.Success ?: return + + // Optimistic update + val updatedPosts = currentState.posts.map { status -> + val target = status.reblog ?: status + if (target.id == statusId) { + val updated = target.copy( + reblogged = !target.reblogged, + reblogsCount = target.reblogsCount + if (target.reblogged) -1 else 1, + ) + if (status.reblog != null) status.copy(reblog = updated) else updated + } else { + status + } + } + _uiState.value = TimelineUiState.Success(updatedPosts) + + // Fire API call + viewModelScope.launch { + try { + val target = currentState.posts.firstNotNullOfOrNull { s -> + val t = s.reblog ?: s; if (t.id == statusId) t else null + } ?: return@launch + if (target.reblogged) { + repository.unreblogStatus(auth, statusId) + } else { + repository.reblogStatus(auth, statusId) + } + } catch (_: Exception) { + refresh() + } + } + } +} diff --git a/android/wear/src/main/res/drawable/ic_add.xml b/android/wear/src/main/res/drawable/ic_add.xml new file mode 100644 index 00000000..357eec70 --- /dev/null +++ b/android/wear/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/wear/src/main/res/drawable/ic_mic.xml b/android/wear/src/main/res/drawable/ic_mic.xml new file mode 100644 index 00000000..02117364 --- /dev/null +++ b/android/wear/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..4df90c3c --- /dev/null +++ b/android/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..4df90c3c --- /dev/null +++ b/android/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/wear/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/wear/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..90ce47cc Binary files /dev/null and b/android/wear/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/wear/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/wear/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..5db16075 Binary files /dev/null and b/android/wear/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/wear/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/android/wear/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp new file mode 100644 index 00000000..f590f2b0 Binary files /dev/null and b/android/wear/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/android/wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..5245fda5 Binary files /dev/null and b/android/wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/wear/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/wear/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..6b52f442 Binary files /dev/null and b/android/wear/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/wear/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/wear/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..79b266c8 Binary files /dev/null and b/android/wear/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/wear/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/android/wear/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp new file mode 100644 index 00000000..f8234fa2 Binary files /dev/null and b/android/wear/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/android/wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..65f0f453 Binary files /dev/null and b/android/wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/wear/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/wear/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..9ae37ce9 Binary files /dev/null and b/android/wear/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/wear/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/wear/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..90c0dabf Binary files /dev/null and b/android/wear/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/wear/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/android/wear/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp new file mode 100644 index 00000000..da547426 Binary files /dev/null and b/android/wear/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/android/wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1be4151c Binary files /dev/null and b/android/wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..2118bb48 Binary files /dev/null and b/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..40c2f1a7 Binary files /dev/null and b/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 00000000..1a4adf80 Binary files /dev/null and b/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..67b59bb8 Binary files /dev/null and b/android/wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..a73ba6c0 Binary files /dev/null and b/android/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..3de3ee87 Binary files /dev/null and b/android/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/android/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 00000000..63908e3f Binary files /dev/null and b/android/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/android/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..6f110682 Binary files /dev/null and b/android/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/wear/src/main/res/values/ic_launcher_background.xml b/android/wear/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..14fdd42d --- /dev/null +++ b/android/wear/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #EFC4C4 + diff --git a/android/wear/src/main/res/values/strings.xml b/android/wear/src/main/res/values/strings.xml new file mode 100644 index 00000000..038028c0 --- /dev/null +++ b/android/wear/src/main/res/values/strings.xml @@ -0,0 +1,39 @@ + + + Coho + Home + Trending + Notifications + Loading… + Can\'t connect. Check your watch\'s internet. + Open Coho on your phone and tap \"Sync to Watch\" in Settings. + Connect your account + Pull to refresh + Favorite + Boost + %s boosted + followed you + favorited your post + boosted your post + mentioned you + poll ended + posted + edited a post + just now + %dm ago + %dh ago + %dd ago + Compose + What\'s on your mind? + Try again + Post + Posting… + Failed to post + No speech detected. Tap to try again. + New post + Settings + Log out + Tap again to log out + Replying to %s + In reply to %s + diff --git a/capacitor.config.ts b/capacitor.config.ts new file mode 100644 index 00000000..62f305b1 --- /dev/null +++ b/capacitor.config.ts @@ -0,0 +1,27 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'place.coho.app', + appName: 'Coho', + webDir: 'dist', + server: { + // Use HTTPS scheme so the WebView origin matches typical browser behavior. + // This affects CORS: requests from the WebView will have origin "https://localhost". + androidScheme: 'https', + }, + plugins: { + App: { + // Disable Capacitor's default back-button handler so our custom + // OnBackPressedCallback in MainActivity can dynamically enable/disable + // itself based on WebView.canGoBack(). This is required for the + // predictive back gesture animation on Android 14+. + disableBackButtonHandler: true, + }, + PushNotifications: { + // Show notifications even when the app is in the foreground + presentationOptions: ['badge', 'sound', 'alert'], + }, + }, +}; + +export default config; diff --git a/firebase.json b/firebase.json index dc85fdd2..6f95a23c 100644 --- a/firebase.json +++ b/firebase.json @@ -67,6 +67,19 @@ "value": "no-cache" } ] + }, + { + "source": "/.well-known/assetlinks.json", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Cache-Control", + "value": "no-cache" + } + ] } ], "rewrites": [ diff --git a/functions/package-lock.json b/functions/package-lock.json index 3f20fd1d..f08333c6 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "firebase-admin": "^12.6.0", "firebase-functions": "^6.0.1", + "http_ece": "^1.2.1", "openai": "^6.8.1" }, "devDependencies": { @@ -3690,6 +3691,15 @@ "dev": true, "license": "MIT" }, + "node_modules/http_ece": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.1.tgz", + "integrity": "sha512-+tzLoMYgXvicu60sVFoswTiu6BiQ6EX3DORRJQ3W2dNpNWCyZ3tcmRFZZ3jgVyw8ziWUCeUARKCkYDY6JgFx+w==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", diff --git a/functions/package.json b/functions/package.json index 59aa27ca..c0bf6244 100644 --- a/functions/package.json +++ b/functions/package.json @@ -16,6 +16,7 @@ "dependencies": { "firebase-admin": "^12.6.0", "firebase-functions": "^6.0.1", + "http_ece": "^1.2.1", "openai": "^6.8.1" }, "devDependencies": { diff --git a/functions/src/index.ts b/functions/src/index.ts index 25939d62..9bc51b75 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -10,6 +10,9 @@ import { defineSecret } from 'firebase-functions/params'; import OpenAI from 'openai'; import * as crypto from 'crypto'; +// Re-export push relay functions +export { pushRelay, pushRelayPush } from './push-relay'; + // Define the secret const openaiApiKey = defineSecret('OPENAI_API_KEY'); @@ -18,6 +21,8 @@ const allowedOrigins = [ 'https://coho.place', 'https://coho-mastodon.web.app', 'http://localhost:3000', + // Capacitor Android WebView origin (androidScheme: 'https' in capacitor.config.ts) + 'https://localhost', ]; const applyCors = ( diff --git a/functions/src/push-relay.ts b/functions/src/push-relay.ts new file mode 100644 index 00000000..6808f259 --- /dev/null +++ b/functions/src/push-relay.ts @@ -0,0 +1,437 @@ +/** + * Push Relay — bridges Mastodon Web Push to FCM for Capacitor Android. + * + * Mastodon sends encrypted Web Push messages to an endpoint URL. + * This relay: + * 1. Registers an FCM token + generates ECDH keys → returns a Web Push–compatible + * subscription (endpoint + keys) that can be sent to Mastodon. + * 2. Receives incoming Web Push POSTs from Mastodon, decrypts them, and + * forwards the JSON payload to the device via FCM. + * 3. Supports unregistration to clean up Firestore entries. + */ + +import { onRequest, type Request } from 'firebase-functions/v2/https'; +import type { Response } from 'express'; +import * as logger from 'firebase-functions/logger'; +import * as admin from 'firebase-admin'; +import * as crypto from 'crypto'; +import * as http from 'http'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const ece = require('http_ece'); + +// --------------------------------------------------------------------------- +// Patch: strip non-standard Content-Encoding from Web Push requests +// --------------------------------------------------------------------------- +// Mastodon sends Content-Encoding: aesgcm (an *application-level* encryption +// scheme, not an HTTP transfer encoding). The Cloud Functions runtime's +// body-parser rejects anything it can't decompress (gzip/deflate/br/identity) +// with a 415 — and there's no way to disable or configure it. +// +// This patch intercepts the Node.js HTTP 'request' event (which fires before +// Express routing) and moves the non-standard value to x-content-encoding so +// body-parser sees no encoding and parses the raw bytes normally. +// --------------------------------------------------------------------------- + +const WEB_PUSH_ENCODINGS = new Set(['aesgcm', 'aes128gcm']); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const _origEmit: (...args: any[]) => boolean = http.Server.prototype.emit; + +http.Server.prototype.emit = function ( + this: any, + event: string, + ...args: any[] +): boolean { + if (event === 'request') { + const req = args[0]; + if (req?.headers) { + const ce: string | undefined = req.headers['content-encoding']; + if (ce && WEB_PUSH_ENCODINGS.has(ce)) { + req.headers['x-content-encoding'] = ce; + delete req.headers['content-encoding']; + } + } + } + return _origEmit.apply(this, [event, ...args]); +}; + +// --------------------------------------------------------------------------- +// Firebase Admin Initialisation (idempotent) +// --------------------------------------------------------------------------- + +if (!admin.apps.length) { + admin.initializeApp(); +} + +const db = admin.firestore(); +const messaging = admin.messaging(); + +const COLLECTION = 'push-relay-registrations'; + +// --------------------------------------------------------------------------- +// CORS (same list used by the rest of the functions) +// --------------------------------------------------------------------------- + +const allowedOrigins = [ + 'https://coho.place', + 'https://coho-mastodon.web.app', + 'http://localhost:3000', + 'https://localhost', // Capacitor Android WebView +]; + +function applyCors( + request: { headers: { origin?: string } }, + response: { set: (key: string, value: string) => void } +) { + const origin = request.headers.origin; + if (origin && allowedOrigins.includes(origin)) { + response.set('Access-Control-Allow-Origin', origin); + } + response.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + response.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); +} + +// --------------------------------------------------------------------------- +// Crypto helpers +// --------------------------------------------------------------------------- + +/** Generate an ECDH P-256 key pair and a 16-byte auth secret. */ +function generateSubscriptionKeys() { + const ecdh = crypto.createECDH('prime256v1'); + ecdh.generateKeys(); + + // Public key in uncompressed format (65 bytes) + const publicKey = ecdh.getPublicKey(); + const privateKey = ecdh.getPrivateKey(); + + // Random 16-byte auth secret + const auth = crypto.randomBytes(16); + + return { + p256dh: publicKey.toString('base64url'), + auth: auth.toString('base64url'), + // Stored server-side only — never sent to the client + privateKeyHex: privateKey.toString('hex'), + authHex: auth.toString('hex'), + }; +} + +// --------------------------------------------------------------------------- +// Web Push Decryption (RFC 8291 — aes128gcm, via http_ece) +// --------------------------------------------------------------------------- + +/** + * Parse a named parameter (e.g. salt=… or dh=…) from an HTTP header value. + * Parameters are separated by ; or ,. + */ +function parseHeaderParam(header: string, param: string): string | null { + const match = header.match( + new RegExp(`(?:^|[;,])\\s*${param}=([^;,\\s]+)`, 'i') + ); + return match ? match[1] : null; +} + +/** + * Decrypt a Web Push payload. + * + * Supports both encryption schemes: + * - **aesgcm** (draft-ietf-webpush-encryption) – used by most Mastodon + * instances. Salt and sender DH key are in the Encryption / Crypto-Key + * request headers. + * - **aes128gcm** (RFC 8291) – salt and key-id are embedded in the payload. + */ +function decryptPayload( + body: Buffer, + privateKeyHex: string, + authHex: string, + contentEncoding: string, + encryptionHeader?: string, + cryptoKeyHeader?: string +): string { + const receiverEcdh = crypto.createECDH('prime256v1'); + receiverEcdh.setPrivateKey(Buffer.from(privateKeyHex, 'hex')); + const authSecret = Buffer.from(authHex, 'hex'); + + if (contentEncoding === 'aesgcm') { + const salt = parseHeaderParam(encryptionHeader || '', 'salt'); + const dh = parseHeaderParam(cryptoKeyHeader || '', 'dh'); + if (!salt || !dh) { + throw new Error( + 'Missing Encryption/Crypto-Key header parameters for aesgcm' + ); + } + const decrypted: Buffer = ece.decrypt(body, { + version: 'aesgcm', + privateKey: receiverEcdh, + dh: Buffer.from(dh, 'base64url'), + salt: Buffer.from(salt, 'base64url'), + authSecret, + }); + return decrypted.toString('utf-8'); + } + + // aes128gcm (RFC 8291) – self-contained payload + const decrypted: Buffer = ece.decrypt(body, { + version: 'aes128gcm', + privateKey: receiverEcdh, + authSecret, + }); + return decrypted.toString('utf-8'); +} + +// --------------------------------------------------------------------------- +// Validate FCM token format (basic sanity check) +// --------------------------------------------------------------------------- +function isValidFcmToken(token: string): boolean { + return ( + typeof token === 'string' && token.length >= 32 && token.length <= 4096 + ); +} + +// --------------------------------------------------------------------------- +// Firebase Functions +// --------------------------------------------------------------------------- + +/** + * POST /pushRelay body: { action: 'register', fcmToken } + * → { registrationId, endpoint, keys: { p256dh, auth } } + * + * DELETE /pushRelay body: { action: 'unregister', registrationId } + * → { success: true } + * + * The push reception endpoint is a *separate* function (pushRelayPush) so Mastodon + * can POST raw encrypted bytes to it. + */ +export const pushRelay = onRequest( + async (request: Request, response: Response) => { + if (request.method === 'OPTIONS') { + applyCors(request, response); + response.status(204).send(''); + return; + } + + applyCors(request, response); + + const action: string = request.body?.action || request.query.action || ''; + + // ── Register ────────────────────────────────────────────── + if (request.method === 'POST' && action === 'register') { + const fcmToken: string = request.body?.fcmToken; + + if (!fcmToken || !isValidFcmToken(fcmToken)) { + response.status(400).json({ error: 'Valid fcmToken is required' }); + return; + } + + try { + const keys = generateSubscriptionKeys(); + const registrationId = crypto.randomUUID(); + + await db.collection(COLLECTION).doc(registrationId).set({ + fcmToken, + p256dh: keys.p256dh, + auth: keys.auth, + privateKeyHex: keys.privateKeyHex, + authHex: keys.authHex, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }); + + // Build the Web Push–compatible endpoint that Mastodon will POST to. + // The URL is the pushRelayPush function URL. + const projectId = + process.env.GCLOUD_PROJECT || + process.env.GCP_PROJECT || + JSON.parse(process.env.FIREBASE_CONFIG || '{}').projectId || + 'coho-mastodon'; + const region = process.env.FUNCTION_REGION || 'us-central1'; + const endpoint = `https://${region}-${projectId}.cloudfunctions.net/pushRelayPush/${registrationId}`; + + logger.info('Push relay registered', { registrationId }); + + response.json({ + registrationId, + endpoint, + keys: { + p256dh: keys.p256dh, + auth: keys.auth, + }, + }); + } catch (error) { + logger.error('Push relay registration failed', { error }); + response.status(500).json({ error: 'Registration failed' }); + } + return; + } + + // ── Unregister ──────────────────────────────────────────── + if ( + (request.method === 'POST' && action === 'unregister') || + request.method === 'DELETE' + ) { + const registrationId: string = + request.body?.registrationId || request.query.registrationId || ''; + + if (!registrationId) { + response.status(400).json({ error: 'registrationId is required' }); + return; + } + + try { + await db.collection(COLLECTION).doc(registrationId).delete(); + logger.info('Push relay unregistered', { registrationId }); + response.json({ success: true }); + } catch (error) { + logger.error('Push relay unregistration failed', { error }); + response.status(500).json({ error: 'Unregistration failed' }); + } + return; + } + + response.status(400).json({ error: 'Invalid action' }); + } +); + +/** + * Receives incoming Web Push payloads from Mastodon. + * + * Mastodon POSTs encrypted bytes to: + * https://-.cloudfunctions.net/pushRelayPush/ + * + * The non-standard Content-Encoding header (aesgcm) is stripped by the + * http.Server.prototype.emit patch above and saved as x-content-encoding. + */ +export const pushRelayPush = onRequest( + async (request: Request, response: Response) => { + if (request.method !== 'POST') { + response.status(405).send('Method Not Allowed'); + return; + } + + // Extract registrationId from the URL path + const pathParts = request.path.split('/').filter(Boolean); + const registrationId = pathParts[pathParts.length - 1]; + + if (!registrationId) { + response.status(400).send('Missing registrationId'); + return; + } + + try { + const doc = await db.collection(COLLECTION).doc(registrationId).get(); + if (!doc.exists) { + response.status(404).send('Registration not found'); + return; + } + + const data = doc.data()!; + const { fcmToken, privateKeyHex, authHex } = data; + + // Original Content-Encoding saved by the http.Server patch above + const contentEncoding = + (request.headers['x-content-encoding'] as string) || 'aes128gcm'; + + logger.info('Incoming push', { + registrationId, + contentEncoding, + hasEncryptionHeader: !!request.headers['encryption'], + hasCryptoKeyHeader: !!request.headers['crypto-key'], + bodyLength: request.rawBody?.length ?? 0, + }); + + // rawBody is always available on Firebase Functions requests + const rawBody = Buffer.isBuffer(request.rawBody) + ? request.rawBody + : Buffer.from(request.rawBody || ''); + + let decryptedJson: string; + try { + decryptedJson = decryptPayload( + rawBody, + privateKeyHex, + authHex, + contentEncoding, + request.headers['encryption'] as string | undefined, + request.headers['crypto-key'] as string | undefined + ); + } catch (decryptError) { + logger.error('Failed to decrypt push payload', { decryptError }); + response.status(201).send('Accepted (decrypt failed)'); + return; + } + + let payload: Record; + try { + payload = JSON.parse(decryptedJson); + } catch { + logger.error('Decrypted payload is not valid JSON', { + decryptedJson: decryptedJson.substring(0, 200), + }); + response.status(201).send('Accepted (invalid JSON)'); + return; + } + + const title = String(payload.title || 'Coho'); + const body = String(payload.body || 'You have a new notification'); + const notificationType = String(payload.notification_type || ''); + // Map Mastodon notification types to Android notification channels + const channelMap: Record = { + mention: 'coho_mentions', + reblog: 'coho_boosts', + favourite: 'coho_favourites', + follow: 'coho_follows', + follow_request: 'coho_follows', + poll: 'coho_polls', + status: 'coho_status', + update: 'coho_status', + }; + const channelId = channelMap[notificationType] || 'coho_general'; + + // FCM data values must all be strings — stringify everything + const fcmData: Record = {}; + for (const [key, value] of Object.entries(payload)) { + fcmData[key] = + typeof value === 'string' ? value : JSON.stringify(value); + } + // Ensure required keys exist + fcmData.title = title; + fcmData.body = body; + + await messaging.send({ + token: fcmToken, + notification: { + title, + body, + }, + data: fcmData, + android: { + priority: 'high', + notification: { + channelId, + icon: 'ic_stat_name', + color: '#d6325c', + tag: `coho_${notificationType || 'general'}`, + }, + }, + }); + + logger.info('Push relayed to FCM', { + registrationId, + type: payload.notification_type, + }); + response.status(201).send('Created'); + } catch (error) { + logger.error('Push relay delivery failed', { error }); + + if ( + error instanceof Error && + (error.message.includes('not-registered') || + error.message.includes('invalid-registration-token')) + ) { + await db.collection(COLLECTION).doc(registrationId).delete(); + logger.info('Cleaned up stale registration', { registrationId }); + } + + response.status(500).send('Relay failed'); + } + } +); diff --git a/index.html b/index.html index be10f2e9..e3329514 100644 --- a/index.html +++ b/index.html @@ -190,6 +190,33 @@ + + + diff --git a/package-lock.json b/package-lock.json index 34520c81..1f68eda7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,12 @@ "version": "0.0.1", "license": "ISC", "dependencies": { + "@capacitor/app": "^8.0.1", + "@capacitor/browser": "^8.0.2", + "@capacitor/core": "^8.2.0", + "@capacitor/haptics": "^8.0.1", + "@capacitor/push-notifications": "^8.0.2", + "@capacitor/share": "^8.0.1", "@huggingface/transformers": "^4.0.0-next.2", "@lit-labs/compiler": "^1.1.2", "@lit-labs/virtualizer": "^2.1.1", @@ -24,6 +30,8 @@ "web-router": "^0.5.0" }, "devDependencies": { + "@capacitor/android": "^8.2.0", + "@capacitor/cli": "^8.2.0", "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.1", "@lit-labs/ssr": "^4.0.0", @@ -135,6 +143,129 @@ "node": ">=18" } }, + "node_modules/@capacitor/android": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.2.0.tgz", + "integrity": "sha512-XLm5OsWLPfXQxDxzFS7SOdMEgGvW+2c7TGLXkTR2cSKdkWK5Abns4imlT5qghKYhjM9r74IrDkBWg/9ALUGNKQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.2.0" + } + }, + "node_modules/@capacitor/app": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.0.1.tgz", + "integrity": "sha512-yeG3yyA0ETKqvgqexwHMBlmVOF13A1hRXzv/km0Ptv5TrNIZvZJK4MTI3uiqvnbHrzoJGP5DwWAjEXEfi90v3Q==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/browser": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.2.tgz", + "integrity": "sha512-oJEtVP5huwPJ8GgOCH3OewhsBPTW6aVtWg8hU6g65MxBdhkcAKyzYrhsyXupC5GaMxltqLf1JzKETMmGkh6tDw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.2.0.tgz", + "integrity": "sha512-1cMEk0d/I6tl1U+v/lnJR5Oylpx8ZBIHrvQxD5zK0MkjYOUyQAAGJgh97rkhGJqjAUvrGpa8H4BmyhNQN9a17A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@capacitor/cli/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@capacitor/core": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.2.0.tgz", + "integrity": "sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/haptics": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.1.tgz", + "integrity": "sha512-8v8rowLBMeb3CryqoQvXndwyUsoi4pPXf0qFw7IGA4D32Uk7+K6juN2SjRowqunoovkvvbFmU9TD7JIAz2zmFw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/push-notifications": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.0.2.tgz", + "integrity": "sha512-mej8Eh4EU4JXbaeyMX2l+4NNK6IdY5iRsEXpBOTHLD29fuCACBP7kOJTiJv6piw134zdlbFH0rR+sDKHIiFOFg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/share": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.1.tgz", + "integrity": "sha512-3cSBKBCJVon54rKDROP2rqGyeGks4pBh9TbaEk9S375Kbek/ZHe72N50zIa0Vn9Eac/SuhwgehO/mmA4CsUOiw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", @@ -1457,6 +1588,251 @@ } } }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2597,6 +2973,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/statuses": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", @@ -3428,6 +3811,16 @@ "node": ">=8" } }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/axe-core": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", @@ -3445,6 +3838,37 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -3452,6 +3876,19 @@ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT" }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3482,6 +3919,16 @@ "integrity": "sha512-JveqW2w6pEZqFEEfMgCszXzYpE89dG+nPsmOdcs741mFFAROeL+iqjGEpR07RI+s0YY0EFr+4KnOoACprJTpOw==", "license": "Apache-2.0" }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3607,6 +4054,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -4077,6 +4534,19 @@ "dev": true, "license": "MIT" }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4120,6 +4590,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -4612,6 +5092,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -5250,6 +5740,16 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/internal-ip": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-6.2.0.tgz", @@ -5657,6 +6157,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/koa": { "version": "2.16.3", "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.3.tgz", @@ -6269,6 +6779,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -6419,6 +6952,32 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6724,6 +7283,13 @@ "node": ">=8" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/package-json-type": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/package-json-type/-/package-json-type-1.0.3.tgz", @@ -6820,6 +7386,33 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -6844,6 +7437,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6943,6 +7543,21 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, "node_modules/pngjs": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", @@ -7008,6 +7623,30 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -7126,6 +7765,21 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -7284,6 +7938,83 @@ "dev": true, "license": "MIT" }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -7537,6 +8268,13 @@ "dev": true, "license": "MIT" }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -7761,6 +8499,13 @@ "node": ">=18" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -7842,6 +8587,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -7879,6 +8634,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -7998,6 +8763,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/terser": { "version": "5.44.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", @@ -8024,6 +8806,16 @@ "dev": true, "license": "MIT" }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -8173,6 +8965,16 @@ "node": ">=16" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", @@ -8350,6 +9152,16 @@ "url": "https://github.com/sponsors/kettanaito" } }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -8366,6 +9178,13 @@ "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -8845,6 +9664,40 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -8855,6 +9708,16 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", @@ -8948,6 +9811,17 @@ "node": ">=8" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/ylru": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", diff --git a/package.json b/package.json index cdb6a4d0..134ce4ba 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,12 @@ "author": "", "license": "ISC", "dependencies": { + "@capacitor/app": "^8.0.1", + "@capacitor/browser": "^8.0.2", + "@capacitor/core": "^8.2.0", + "@capacitor/haptics": "^8.0.1", + "@capacitor/push-notifications": "^8.0.2", + "@capacitor/share": "^8.0.1", "@huggingface/transformers": "^4.0.0-next.2", "@lit-labs/compiler": "^1.1.2", "@lit-labs/virtualizer": "^2.1.1", @@ -58,6 +64,8 @@ "web-router": "^0.5.0" }, "devDependencies": { + "@capacitor/android": "^8.2.0", + "@capacitor/cli": "^8.2.0", "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.1", "@lit-labs/ssr": "^4.0.0", diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json index bb537530..3e816b78 100644 --- a/public/.well-known/assetlinks.json +++ b/public/.well-known/assetlinks.json @@ -1,10 +1,14 @@ -[{ - "relation": ["delegate_permission/common.handle_all_urls"], - "target": { - "namespace": "android_app", - "package_name": "coho.mastodon.twa", - "sha256_cert_fingerprints": [ - "11:B4:5F:D0:8B:F6:6D:6E:CC:FC:5A:5D:FA:EB:46:14:64:21:D5:8A:64:9A:02:0A:C4:DA:AF:05:71:A5:32:CB" - ] + +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "place.coho.app", + "sha256_cert_fingerprints": [ + "B0:9F:08:87:66:03:4A:D1:BF:6D:E8:08:6B:C0:05:4F:63:8E:1A:39:E9:05:3C:40:83:1C:B8:F6:40:C6:A6:E6", + "82:20:E9:33:F1:68:59:CF:76:A9:AB:E6:74:3C:6E:00:A6:BF:B0:DF:13:F9:10:DE:67:79:E9:BF:95:AD:FF:DA" + ] + } } -}] +] \ No newline at end of file diff --git a/scripts/test-push.mjs b/scripts/test-push.mjs new file mode 100644 index 00000000..e3d425d4 --- /dev/null +++ b/scripts/test-push.mjs @@ -0,0 +1,54 @@ +/** + * Quick script to send a test FCM push notification. + * Run with: node scripts/test-push.mjs + * + * Requires: GOOGLE_APPLICATION_CREDENTIALS env var pointing to a + * Firebase service account key JSON file, OR run from a machine + * authenticated with `gcloud auth application-default login`. + */ + +import { initializeApp, cert, getApps } from 'firebase-admin/app'; +import { getMessaging } from 'firebase-admin/messaging'; + +// ── Config ────────────────────────────────────────────────────── +// Paste the FCM token from your device/emulator logs here: +const FCM_TOKEN = + 'cwLimXX-QJ2ku6R8dtYZQr:APA91bEZ-NHZIEph_KKpEamRXC0GvsdHnIUdLm2GKzAreL5m3VDx616oz5sE9ET6aTbIheKo0AGrSGIPHYnOXA2R0oDtzI6dOU4PvIweA2SclEg5rSkDcgk'; + +// ── Init Firebase Admin ───────────────────────────────────────── +if (!getApps().length) { + initializeApp({ projectId: 'coho-mastodon' }); +} + +// ── Send test message ─────────────────────────────────────────── +// This mimics the exact data payload the push relay sends after +// decrypting a Mastodon Web Push notification. +const message = { + token: FCM_TOKEN, + data: { + title: 'Test User mentioned you', + body: 'Hey! This is a test notification from the push relay.', + notification_id: '12345', + notification_type: 'mention', + icon: '', + access_token: '', + preferred_locale: 'en', + }, + android: { + priority: 'high', + }, +}; + +try { + const messageId = await getMessaging().send(message); + console.log('✅ Message sent successfully:', messageId); +} catch (error) { + console.error('❌ Failed to send:', error.message); + if (error.message.includes('credential')) { + console.log( + '\nMake sure you are authenticated. Run:\n' + + ' gcloud auth application-default login\n' + + 'Or set GOOGLE_APPLICATION_CREDENTIALS to a service account key.' + ); + } +} diff --git a/src/app-index.ts b/src/app-index.ts index f0b9a8f8..b1dcc39a 100644 --- a/src/app-index.ts +++ b/src/app-index.ts @@ -69,6 +69,10 @@ export class AppIndex extends LitElement { console.error('[App] Failed to bootstrap auth session', error); } + // If the app was cold-launched via an OAuth deep link (App Link / Universal Link), + // consume the launch URL and complete the token exchange before routing starts. + await this.handleNativeLaunchCallback(); + // Register route-changed listener BEFORE router.init() to avoid missing the // initial route-changed event in browsers with native Navigation API + URLPattern // support (where init() completes synchronously before firstUpdated() runs). @@ -88,10 +92,23 @@ export class AppIndex extends LitElement { // Ensure the initial route renders after init completes this.requestUpdate(); - // Defer PWA update component - not needed immediately, loads on browser idle - requestIdleCallback(() => import('./components/pwa-update'), { - timeout: 5000, - }); + // Defer PWA update component - not needed in Capacitor native shell + const { isNativePlatform } = await import('./utils/platform.js'); + if (!isNativePlatform()) { + requestIdleCallback(() => import('./components/pwa-update'), { + timeout: 5000, + }); + } else { + // Initialize native push notification listeners (FCM via Capacitor) + requestIdleCallback( + async () => { + const { setupNativePushListeners } = + await import('./services/push-native.js'); + setupNativePushListeners(); + }, + { timeout: 5000 } + ); + } } disconnectedCallback() { @@ -99,15 +116,46 @@ export class AppIndex extends LitElement { router.removeEventListener('route-changed', this._onRouteChanged); } + /** + * Check if the app was cold-launched via a native deep link carrying OAuth + * callback params. If so, complete the token exchange so the user lands + * authenticated when routing initializes. + */ + private async handleNativeLaunchCallback() { + try { + const { isNativePlatform } = await import('./utils/platform.js'); + if (!isNativePlatform()) return; + + const { consumeLaunchCallback } = + await import('./services/auth-platform.js'); + const params = await consumeLaunchCallback(); + if (params) { + const { authToClient } = await import('./services/account.js'); + await authToClient(params.code, params.state); + } + } catch (error) { + console.error('[App] Native launch callback failed', error); + } + } + async handleInitTheme() { const { getSettings } = await import('./services/settings'); const settings = await getSettings(); console.log('settings', settings); - const potentialColor = settings.primary_color; - const { applyThemeColor } = await import('./utils/theme-color'); + // On Android Capacitor, always use the device's Material You accent color + const { getAndroidDynamicColor } = await import('./utils/dynamic-theme'); + const deviceColor = await getAndroidDynamicColor(); + if (deviceColor) { + localStorage.setItem('coho-theme-color', deviceColor); + applyThemeColor(deviceColor, { useIdleCallback: true }); + return; + } + + const potentialColor = settings.primary_color; + if (potentialColor) { // Sync to localStorage for instant theme on next load (migration for existing users) if (!localStorage.getItem('coho-theme-color')) { @@ -124,15 +172,14 @@ export class AppIndex extends LitElement { } firstUpdated() { - // Sync localStorage credentials to IndexedDB for service worker access - // and determine authentication state - this.syncCredentialsToIndexedDB(); - // Check initial authentication state this.checkAuthenticationState(); console.log('[App] isAuthenticated:', this.isAuthenticated); if (this.isAuthenticated) { + // Sync localStorage credentials to IndexedDB for service worker access + this.syncCredentialsToIndexedDB(); + this.handleInitTheme(); // Preload data during idle time, then precache critical components. @@ -262,6 +309,33 @@ export class AppIndex extends LitElement { private async syncCredentialsToIndexedDB() { await syncActiveToIndexedDb(); console.log('[App] Synced credentials to IndexedDB'); + + // Sync server URL to native SharedPreferences for the Android widget + this.syncServerToNativeWidget(); + + // Keep watch in sync automatically (best-effort, non-blocking) + import('./services/wear-sync.js') + .then((m) => m.syncCredentialsToWearOS()) + .catch(() => {}); + } + + /** + * Pushes the current server URL to the native Android widget + * via the WidgetBridge Capacitor plugin, so the widget can + * fetch trending data from the correct Mastodon instance. + */ + private async syncServerToNativeWidget() { + try { + const { isNativePlatform } = await import('./utils/platform.js'); + if (!isNativePlatform()) return; + + const { registerPlugin } = await import('@capacitor/core'); + const WidgetBridge = registerPlugin('WidgetBridge'); + const server = localStorage.getItem('server') || 'mastodon.social'; + await (WidgetBridge as any).setServer({ server }); + } catch { + // Widget bridge not available — ignore + } } /** diff --git a/src/components/app-theme.ts b/src/components/app-theme.ts index 39f2d800..c29155ac 100644 --- a/src/components/app-theme.ts +++ b/src/components/app-theme.ts @@ -6,6 +6,7 @@ import './md/md-icon.js'; import { getSettings, setSettings, Settings } from '../services/settings'; import { applyThemeColor } from '../utils/theme-color'; +import { getAndroidDynamicColor } from '../utils/dynamic-theme'; @customElement('app-theme') export class AppTheme extends LitElement { @@ -144,24 +145,31 @@ export class AppTheme extends LitElement { this.settings = await getSettings(); console.log('this.settings', this.settings); - const potentialColor = this.settings.primary_color; - const potentialFontSize = this.settings.font_size; - - if (potentialColor) { - this.primary_color = potentialColor; - applyThemeColor(potentialColor); + // On Android Capacitor, device color always wins + const deviceColor = await getAndroidDynamicColor(); + if (deviceColor) { + this.primary_color = deviceColor; + applyThemeColor(deviceColor); } else { - // get css variable color - const color = getComputedStyle(document.body).getPropertyValue( - '--sl-color-primary-600' - ); - this.primary_color = color; - - document - .querySelector('html')! - .style.setProperty('--primary-color', color); + const potentialColor = this.settings.primary_color; + + if (potentialColor) { + this.primary_color = potentialColor; + applyThemeColor(potentialColor); + } else { + // get css variable color + const color = getComputedStyle(document.body).getPropertyValue( + '--sl-color-primary-600' + ); + this.primary_color = color; + + document + .querySelector('html')! + .style.setProperty('--primary-color', color); + } } + const potentialFontSize = this.settings.font_size; if (potentialFontSize) { this.font_size = potentialFontSize; document.body.style.setProperty( diff --git a/src/components/header.ts b/src/components/header.ts index 84319be1..61a502a0 100644 --- a/src/components/header.ts +++ b/src/components/header.ts @@ -1,11 +1,10 @@ -import { LitElement, css, html, PropertyValueMap, nothing } from 'lit'; +import { LitElement, css, html, nothing } from 'lit'; import { property, customElement } from 'lit/decorators.js'; import { localized, msg } from '@lit/localize'; import './md/md-icon.js'; import './md/md-icon-button.js'; -import { enableVibrate } from '../utils/handle-vibrate'; import { setAuthRedirect } from '../utils/auth-redirect'; import type { @@ -41,7 +40,7 @@ export class AppHeader extends LitElement { padding-right: 5px; position: fixed; left: env(titlebar-area-x, 0); - top: env(titlebar-area-y, 0); + top: env(titlebar-area-y, env(safe-area-inset-top, 0)); right: 0; app-region: drag; @@ -57,6 +56,7 @@ export class AppHeader extends LitElement { backdrop-filter: unset; background: var(--md-sys-color-background); height: calc(env(titlebar-area-height, 33px) - 4px); + background: transparent; } #actions { @@ -157,16 +157,6 @@ export class AppHeader extends LitElement { `; } - protected firstUpdated( - _changedProperties: PropertyValueMap | Map - ): void { - window.requestIdleCallback(() => { - if (this.shadowRoot) { - enableVibrate(this.shadowRoot); - } - }); - } - openSettings() { // fire custom event this.dispatchEvent(new CustomEvent('open-settings') as OpenSettingsEvent); diff --git a/src/components/home-sidebar.ts b/src/components/home-sidebar.ts index 6695410e..c6b23239 100644 --- a/src/components/home-sidebar.ts +++ b/src/components/home-sidebar.ts @@ -208,6 +208,16 @@ export class HomeSidebar extends LitElement { text: 'Check out my Mastodon profile!', url: this.user.url, }); + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + else if (window.Capacitor) { + const { Share } = await import('@capacitor/share'); + await Share.share({ + title: 'My Mastodon Profile', + text: 'Check out my Mastodon profile!', + url: this.user.url, + }); } else { await navigator.clipboard.writeText(this.user.url); } diff --git a/src/components/image-preview-dialog.ts b/src/components/image-preview-dialog.ts index 56f5a421..92e6e0a7 100644 --- a/src/components/image-preview-dialog.ts +++ b/src/components/image-preview-dialog.ts @@ -248,9 +248,9 @@ export class ImagePreviewDialog extends LitElement { await new Promise((resolve) => requestAnimationFrame(resolve)); this.updatePlaceholderSize(); - // Lazy load AI service to keep it out of main bundle - const { isPromptAPIAvailable } = await import('../services/ai'); - if (isPromptAPIAvailable() && (!this.alt || this.alt.trim() === '')) { + // Always attempt alt text generation when missing — generateAltText has + // its own fallback chain: native Android → Chrome Prompt API → cloud function + if (!this.alt || this.alt.trim() === '') { this.alt = 'Loading alt text...'; this.handleGenerateAlt(); } diff --git a/src/components/notifications.ts b/src/components/notifications.ts index 3752763c..eef50786 100644 --- a/src/components/notifications.ts +++ b/src/components/notifications.ts @@ -654,11 +654,18 @@ export class Notifications extends LitElement { await scheduler.yield(); } // check push reg - const reg = await navigator.serviceWorker.getRegistration(); - if (reg && reg.pushManager) { - const sub = await reg.pushManager.getSubscription(); - if (sub) { - this.subbed = true; + const { isNativePlatform } = await import('../utils/platform.js'); + if (isNativePlatform()) { + const { isNativePushSubscribed } = + await import('../services/push-native.js'); + this.subbed = isNativePushSubscribed(); + } else { + const reg = await navigator.serviceWorker.getRegistration(); + if (reg && reg.pushManager) { + const sub = await reg.pushManager.getSubscription(); + if (sub) { + this.subbed = true; + } } } diff --git a/src/components/post-composer.ts b/src/components/post-composer.ts index 6d35f37e..62df664f 100644 --- a/src/components/post-composer.ts +++ b/src/components/post-composer.ts @@ -2318,6 +2318,10 @@ export class PostComposer extends LitElement { this.isPublishing = false; this.publishSuccess = true; + import('../utils/haptics').then(({ hapticNotification }) => + hapticNotification('success') + ); + // Brief success flash, then reset and dispatch setTimeout(() => { this.publishSuccess = false; diff --git a/src/components/post-detail-dialog.ts b/src/components/post-detail-dialog.ts index a70a53e3..2e573b50 100644 --- a/src/components/post-detail-dialog.ts +++ b/src/components/post-detail-dialog.ts @@ -35,6 +35,8 @@ export class PostDetailDialog extends LitElement { background-color: var(--md-sys-color-surface, #1e1e24); color: var(--md-sys-color-on-surface, #e6e1e5); overflow: hidden; + padding-top: env(safe-area-inset-top, 0px); + /* padding-bottom: 120px; */ } dialog[open] { @@ -158,6 +160,7 @@ export class PostDetailDialog extends LitElement { height: min(90vh, 900px); max-width: min(720px, calc(100vw - 64px)); max-height: min(90vh, 900px); + padding-top: 0; } .back-button { @@ -334,7 +337,10 @@ export class PostDetailDialog extends LitElement {
${this.post - ? html`` + ? html`` : null}
diff --git a/src/components/post-dialog.ts b/src/components/post-dialog.ts index 7778910f..9ca062c4 100644 --- a/src/components/post-dialog.ts +++ b/src/components/post-dialog.ts @@ -138,6 +138,7 @@ export class PostDialog extends LitElement { md-dialog::part(dialog) { min-width: 100vw; min-height: 100vh; + margin-top: calc(env(safe-area-inset-top, 0px)); } } @@ -164,7 +165,8 @@ export class PostDialog extends LitElement { public async openNewDialog( shareName?: string, - origin?: { x: number; y: number } + origin?: { x: number; y: number }, + shareText?: string ) { await this.updateComplete; await customElements.whenDefined('md-dialog'); @@ -175,6 +177,11 @@ export class PostDialog extends LitElement { this.notifyDialog?.show(); + // Pre-fill composer with shared text (e.g. a URL from another app) + if (shareText && this.composer) { + this.composer.value = shareText; + } + const nameToUse = shareName ?? new URLSearchParams(window.location.search).get('name'); diff --git a/src/components/settings-drawer-content.ts b/src/components/settings-drawer-content.ts index 5ebbfac5..97b47814 100644 --- a/src/components/settings-drawer-content.ts +++ b/src/components/settings-drawer-content.ts @@ -1,5 +1,5 @@ import { LitElement, html, css, nothing } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { msg, str } from '@lit/localize'; import { localized } from '@lit/localize'; @@ -14,8 +14,11 @@ import './md/md-menu-item'; import './md/md-button'; import './md/md-card'; import './md/md-divider'; +import './md/md-toast'; import './account-settings'; +import type { MdToast } from './md/md-toast'; + import type { Account } from '../mastodon/types/account'; import type { Instance } from '../mastodon/types/instance'; import { router } from '../router/routes'; @@ -33,9 +36,14 @@ export class SettingsDrawerContent extends LitElement { @property({ type: Object }) instanceInfo: Instance | null = null; @property({ type: Boolean }) wellnessMode = false; @property({ type: Boolean }) dataSaverMode = false; + @property({ type: Boolean }) hapticsEnabled = true; @property({ type: Boolean }) userTermsLoaded = false; @property({ type: Boolean }) appThemeLoaded = false; + @state() private isAndroid = false; + @state() private syncingToWatch = false; + @state() private syncedToWatch = false; + static styles = css` :host { display: block; @@ -166,6 +174,49 @@ export class SettingsDrawerContent extends LitElement { } `; + connectedCallback() { + super.connectedCallback(); + this.detectPlatform(); + } + + private async detectPlatform() { + try { + const { getPlatform } = await import('../utils/platform.js'); + this.isAndroid = getPlatform() === 'android'; + } catch { + this.isAndroid = false; + } + } + + private async syncToWatch() { + this.syncingToWatch = true; + try { + const { syncCredentialsToWearOS } = + await import('../services/wear-sync.js'); + const success = await syncCredentialsToWearOS(); + if (success) { + this.syncedToWatch = true; + } + this.showSyncToast( + success ? msg('Synced to watch') : msg('No credentials to sync'), + success ? 'success' : 'error' + ); + } catch { + this.showSyncToast(msg('Failed to sync to watch'), 'error'); + } finally { + this.syncingToWatch = false; + } + } + + private showSyncToast(message: string, type: 'success' | 'error') { + const toast = this.shadowRoot?.querySelector('md-toast'); + if (toast) { + toast.setAttribute('message', message); + toast.setAttribute('type', type); + toast.show(); + } + } + private goToFollowers() { if (!this.user) return; router.navigate(`/followers?id=${this.user.id}`); @@ -191,6 +242,15 @@ export class SettingsDrawerContent extends LitElement { text: 'Check out my Mastodon profile!', url: this.user.url, }); + } // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + else if (window.Capacitor) { + const { Share } = await import('@capacitor/share'); + await Share.share({ + title: 'My Mastodon Profile', + text: 'Check out my Mastodon profile!', + url: this.user.url, + }); } else { await navigator.clipboard.writeText(this.user.url); } @@ -248,6 +308,17 @@ export class SettingsDrawerContent extends LitElement { ); } + private handleHapticsToggle(e: Event) { + const checked = (e.target as HTMLInputElement).checked; + this.dispatchEvent( + new CustomEvent('haptics-change', { + detail: { checked }, + bubbles: true, + composed: true, + }) + ); + } + render() { return html`
@@ -358,8 +429,47 @@ export class SettingsDrawerContent extends LitElement {

${msg('Data Saver Mode reduces the amount of data used by Coho.')}

+ + + +
+

${msg('Haptic Feedback')}

+ +
+

+ ${msg('Vibrate on actions like likes, boosts, and publishing.')} +

+ + ${this.isAndroid + ? html` + + +
+

${msg('Sync to Watch')}

+ + ${this.syncedToWatch + ? msg('Synced ✓') + : this.syncingToWatch + ? msg('Syncing…') + : msg('Sync')} + +
+

+ ${msg('Send your account credentials to your Wear OS watch.')} +

+ ` + : nothing} + + diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 73244349..01044ce1 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -905,7 +905,9 @@ export class Timeline extends LitElement { }); if (this._pullDistance >= this._threshold && !this._hapticTriggered) { - if (navigator.vibrate) navigator.vibrate(10); + import('../utils/haptics').then(({ hapticImpact }) => + hapticImpact('medium') + ); this._hapticTriggered = true; } else if (this._pullDistance < this._threshold) { this._hapticTriggered = false; diff --git a/src/components/user-profile.ts b/src/components/user-profile.ts index 6a51e618..e9621571 100644 --- a/src/components/user-profile.ts +++ b/src/components/user-profile.ts @@ -94,15 +94,6 @@ export class UserProfile extends LitElement { `, ]; - async firstUpdated() { - window.requestIdleCallback(async () => { - if (this.shadowRoot) { - const { enableVibrate } = await import('../utils/handle-vibrate'); - enableVibrate(this.shadowRoot); - } - }); - } - async openUser() { // Set viewTransitionName for cross-document view transition // @ts-expect-error - viewTransitionName not yet in CSSStyleDeclaration types diff --git a/src/config/firebase.ts b/src/config/firebase.ts index 9337292e..194ddfdb 100644 --- a/src/config/firebase.ts +++ b/src/config/firebase.ts @@ -16,7 +16,9 @@ const LOCAL_BASE_URL = `http://127.0.0.1:5001/${FIREBASE_PROJECT_ID}/${FIREBASE_ const isLocal = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && - import.meta.env.MODE !== 'test'; + import.meta.env.MODE !== 'test' && + // Capacitor's WebView runs on https://localhost but should use production APIs + window.location.protocol !== 'https:'; // Export the appropriate base URL export const FIREBASE_FUNCTIONS_BASE_URL = isLocal diff --git a/src/generated/locales/de.ts b/src/generated/locales/de.ts index 3d9376e9..58c44ce3 100644 --- a/src/generated/locales/de.ts +++ b/src/generated/locales/de.ts @@ -280,6 +280,8 @@ export const templates = { sfdfc05708c242107: `Wie du für andere erscheinst`, sfe2f8c98d9edbf7a: `Beitrag nicht gefunden`, sff9d51b6c5a73163: `Alt-Text`, + s93c8f5aaa2f14fb6: `Back to login`, + s59169c4e8fbcdccf: `Signing in…`, sddd265e80aabafea: `Audio player`, s061cc20e3432dead: `Pause`, scd105819b5a10243: `Play`, @@ -316,7 +318,17 @@ export const templates = { s70dbcf49cc261e8e: `Default Post Language`, s64a08ea7e5de1afa: `Account & Privacy`, sef8b008b85e8d725: `Posting Defaults`, + s288fdb61a9ecaffb: `Synced to watch`, + s804676468ea1af74: `No credentials to sync`, + sfa39c2b9c0dd0c02: `Failed to sync to watch`, s2b5047d39b9baf3d: `Preferences`, + s399b2152de758495: `Haptic Feedback`, + s1210a6c6ad65d97b: `Vibrate on actions like likes, boosts, and publishing.`, + s5ddb4ae6884197c4: `Sync to Watch`, + s36c13bb456523146: `Synced ✓`, + s9cd4984f1a746b40: `Syncing…`, + s05073d2537b45d1a: `Sync`, + s0c61be2ad8538497: `Send your account credentials to your Wear OS watch.`, saab875d8cfcfe712: `Theme`, s0d29a53a04e80406: `Content & Safety`, s3d8f0d63cd31d3c9: `Content Filters`, @@ -337,6 +349,7 @@ export const templates = { s2bcebb0b6a6517b6: `Search for your server to connect another Mastodon account.`, s626c98f49c83a793: `Search for a Mastodon server`, s6d83773bf37278f8: `Starting OAuth...`, + sf18a42912f62bf8a: `Current account avatar`, s86723627c116f82a: `Failed to load notification preferences.`, sbfdfd594b652f867: `Failed to save notification preferences.`, s5261000c68eafb43: `Push notifications are not enabled.`, diff --git a/src/generated/locales/es.ts b/src/generated/locales/es.ts index 3c7829e5..1750db1a 100644 --- a/src/generated/locales/es.ts +++ b/src/generated/locales/es.ts @@ -280,6 +280,8 @@ export const templates = { sfdfc05708c242107: `Cómo apareces ante los otros`, sfe2f8c98d9edbf7a: `Publicación no encontrada`, sff9d51b6c5a73163: `Texto Alternativo`, + s93c8f5aaa2f14fb6: `Back to login`, + s59169c4e8fbcdccf: `Signing in…`, sddd265e80aabafea: `Audio player`, s061cc20e3432dead: `Pause`, scd105819b5a10243: `Play`, @@ -316,7 +318,17 @@ export const templates = { s70dbcf49cc261e8e: `Default Post Language`, s64a08ea7e5de1afa: `Account & Privacy`, sef8b008b85e8d725: `Posting Defaults`, + s288fdb61a9ecaffb: `Synced to watch`, + s804676468ea1af74: `No credentials to sync`, + sfa39c2b9c0dd0c02: `Failed to sync to watch`, s2b5047d39b9baf3d: `Preferences`, + s399b2152de758495: `Haptic Feedback`, + s1210a6c6ad65d97b: `Vibrate on actions like likes, boosts, and publishing.`, + s5ddb4ae6884197c4: `Sync to Watch`, + s36c13bb456523146: `Synced ✓`, + s9cd4984f1a746b40: `Syncing…`, + s05073d2537b45d1a: `Sync`, + s0c61be2ad8538497: `Send your account credentials to your Wear OS watch.`, saab875d8cfcfe712: `Theme`, s0d29a53a04e80406: `Content & Safety`, s3d8f0d63cd31d3c9: `Content Filters`, @@ -337,6 +349,7 @@ export const templates = { s2bcebb0b6a6517b6: `Search for your server to connect another Mastodon account.`, s626c98f49c83a793: `Search for a Mastodon server`, s6d83773bf37278f8: `Starting OAuth...`, + sf18a42912f62bf8a: `Current account avatar`, s86723627c116f82a: `Failed to load notification preferences.`, sbfdfd594b652f867: `Failed to save notification preferences.`, s5261000c68eafb43: `Push notifications are not enabled.`, diff --git a/src/pages/app-explore.ts b/src/pages/app-explore.ts index 31a9cc69..15586e05 100644 --- a/src/pages/app-explore.ts +++ b/src/pages/app-explore.ts @@ -27,7 +27,7 @@ export class AppExplore extends LitElement { } main { - padding-top: 60px; + padding-top: calc(60px + env(safe-area-inset-top, 0px)); display: grid; grid-template-columns: 2fr 1fr; gap: 10px; diff --git a/src/pages/app-hashtags.ts b/src/pages/app-hashtags.ts index 358b06a5..f29790fc 100644 --- a/src/pages/app-hashtags.ts +++ b/src/pages/app-hashtags.ts @@ -20,8 +20,8 @@ export class AppHashtags extends LitElement { } main { - padding-top: 60px; padding: 10px; + padding-top: calc(60px + env(safe-area-inset-top, 0px)); height: 100%; box-sizing: border-box; display: flex; diff --git a/src/pages/app-home.ts b/src/pages/app-home.ts index 6033d6fd..8d657d34 100644 --- a/src/pages/app-home.ts +++ b/src/pages/app-home.ts @@ -90,6 +90,7 @@ export class AppHome extends LitElement { @state() wellnessMode: boolean = false; @state() dataSaverMode: boolean = false; + @state() hapticsEnabled: boolean = true; @state() summary: string = ''; @@ -226,8 +227,14 @@ export class AppHome extends LitElement { await this.shareTarget(name); } } + + // Check for native Android share intent (cold start) + this._checkNativeShareTarget(); }, 1000); + // Listen for share intents that arrive while the app is already open + this._initNativeShareListener(); + window.requestIdleCallback(async () => { // Import and init key shortcuts const { init } = await import('../utils/key-shortcuts'); @@ -271,6 +278,8 @@ export class AppHome extends LitElement { this.handleWellnessMode(settings.wellness || false); this.handleDataSaverMode(settings.data_saver || false); + + this.handleHapticsMode(settings.haptics !== false); } // Only check notifications for authenticated users @@ -314,13 +323,6 @@ export class AppHome extends LitElement { this.tabController.openATab(tabToOpen); } - window.requestIdleCallback(async () => { - if (this.shadowRoot) { - const { enableVibrate } = await import('../utils/handle-vibrate'); - enableVibrate(this.shadowRoot); - } - }); - // Defer right-click menu and install prompt check - not needed immediately window.requestIdleCallback(() => { this.loadRightClick(); @@ -401,6 +403,49 @@ export class AppHome extends LitElement { } } + private _nativeShareCleanup: { remove: () => void } | null = null; + + /** + * Check for shared content from an Android share intent (cold start). + */ + private async _checkNativeShareTarget() { + const { checkNativeShare } = + await import('../services/native-share-target'); + const result = await checkNativeShare(); + if (result.hasShare) { + this._handleNativeShare(result); + } + } + + /** + * Listen for Android share intents that arrive while the app is already + * running (warm start via onNewIntent). + */ + private async _initNativeShareListener() { + const { onNativeShareIntent } = + await import('../services/native-share-target'); + this._nativeShareCleanup = onNativeShareIntent((result) => { + this._handleNativeShare(result); + }); + } + + /** + * Process a native share intent result — open the compose dialog with + * the shared media file and/or pre-filled text. + */ + private async _handleNativeShare( + result: import('../services/native-share-target').NativeShareResult + ) { + if (result.cachedFileName) { + // Media share — the file is already in the shareTarget cache, + // so openNewDialog → post-dialog shareTarget will pick it up. + await this.openNewDialog(result.cachedFileName, undefined, result.text); + } else if (result.text) { + // Text-only share (e.g. a URL from Chrome) + await this.openNewDialog(undefined, undefined, result.text); + } + } + handlePrimaryColor(color: string) { document.documentElement.style.setProperty('--sl-color-primary-600', color); localStorage.setItem('primary_color', color); @@ -415,7 +460,11 @@ export class AppHome extends LitElement { return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; } - async openNewDialog(shareName?: string, origin?: { x: number; y: number }) { + async openNewDialog( + shareName?: string, + origin?: { x: number; y: number }, + shareText?: string + ) { // Lazy load post-dialog component if (!this.postDialogLoaded) { if (await lazyLoad('postDialog', componentLoaders.postDialog)) { @@ -435,7 +484,7 @@ export class AppHome extends LitElement { // Wait for the post-dialog's own shadow DOM to render if (this.postDialog) { await this.postDialog.updateComplete; - this.postDialog.openNewDialog(shareName, origin); + this.postDialog.openNewDialog(shareName, origin, shareText); } } @@ -490,6 +539,16 @@ export class AppHome extends LitElement { setSettings({ data_saver: mode }); } + async handleHapticsMode(enabled: boolean) { + this.hapticsEnabled = enabled; + + const { setHapticsEnabled } = await import('../utils/haptics'); + setHapticsEnabled(enabled); + + const { setSettings } = await import('../services/settings'); + setSettings({ haptics: enabled }); + } + async handleTabChange(event: TabChangeEvent) { // Determine the load callback: // If we were passed a panel, we might need simple loading @@ -662,6 +721,9 @@ export class AppHome extends LitElement { // Remove keyboard shortcut new post dialog listener window.removeEventListener('open-post-dialog', this._handleOpenPostDialog); + // Remove native share intent listener + this._nativeShareCleanup?.remove(); + const lastPageID = sessionStorage.getItem(getLatestReadStorageKey()); console.log('lastPageID', lastPageID); if (lastPageID) { @@ -770,6 +832,10 @@ export class AppHome extends LitElement { // PWA Install methods async checkInstallPrompt() { + // Skip install prompt entirely when running inside Capacitor native shell + const { isNativePlatform } = await import('../utils/platform'); + if (isNativePlatform()) return; + // Wait a moment for the pwa-install component to initialize await this.updateComplete; @@ -1044,12 +1110,15 @@ export class AppHome extends LitElement { .instanceInfo="${this.instanceInfo}" .wellnessMode="${this.wellnessMode}" .dataSaverMode="${this.dataSaverMode}" + .hapticsEnabled="${this.hapticsEnabled}" .userTermsLoaded="${this.userTermsLoaded}" .appThemeLoaded="${this.appThemeLoaded}" @wellness-change="${(e: CustomEvent<{ checked: boolean }>) => this.handleWellnessMode(e.detail.checked)}" @data-saver-change="${(e: CustomEvent<{ checked: boolean }>) => this.handleDataSaverMode(e.detail.checked)}" + @haptics-change="${(e: CustomEvent<{ checked: boolean }>) => + this.handleHapticsMode(e.detail.checked)}" @open-filters="${() => this.openFiltersDialog()}" @open-scheduled-statuses="${() => this.openScheduledStatusesDialog()}" diff --git a/src/pages/app-login.ts b/src/pages/app-login.ts index c9c8b489..fd8383f5 100644 --- a/src/pages/app-login.ts +++ b/src/pages/app-login.ts @@ -41,6 +41,7 @@ export class AppLogin extends LitElement { width: 100%; background-color: var(--md-sys-color-surface-container); padding: 20px; + padding-top: calc(90px + env(safe-area-inset-top, 0px)) !important; box-sizing: border-box; position: relative; overflow: hidden; @@ -140,18 +141,6 @@ export class AppLogin extends LitElement { timeout: 8000, } ); - - requestIdleCallback( - async () => { - if (this.shadowRoot) { - const { enableVibrate } = await import('../utils/handle-vibrate'); - enableVibrate(this.shadowRoot); - } - }, - { - timeout: 8000, - } - ); } private async init() { @@ -209,8 +198,20 @@ export class AppLogin extends LitElement { } this.loggingIn = true; try { - const { initAuth } = await import('../services/account'); + const { initAuth, authToClient } = await import('../services/account'); await initAuth(serverURL); + + // On native, the OAuth redirect comes back as an appUrlOpen event + // instead of a page navigation, so we listen for it here. + const { isNativePlatform } = await import('../utils/platform'); + if (isNativePlatform()) { + const { waitForNativeCallback } = + await import('../services/auth-platform'); + const { code, state } = await waitForNativeCallback(); + await authToClient(code, state); + const router = await getRouter(); + await router.navigate(await this.getPostAuthRedirect()); + } } catch (err) { console.error(err); } finally { diff --git a/src/pages/app-media.ts b/src/pages/app-media.ts index f9258cec..ef0269e7 100644 --- a/src/pages/app-media.ts +++ b/src/pages/app-media.ts @@ -15,7 +15,7 @@ export class AppMedia extends LitElement { } main { - padding-top: 60px; + padding-top: calc(60px + env(safe-area-inset-top, 0px)); } ul { diff --git a/src/pages/app-messages.ts b/src/pages/app-messages.ts index 4b2d850f..df6dd1ba 100644 --- a/src/pages/app-messages.ts +++ b/src/pages/app-messages.ts @@ -44,7 +44,7 @@ export class AppMessages extends LitElement { display: flex; flex-direction: column; height: 100%; - padding-top: 46px; + padding-top: calc(46px + env(safe-area-inset-top, 0px)); box-sizing: border-box; } diff --git a/src/pages/app-profile.ts b/src/pages/app-profile.ts index bfe856ff..00f64588 100644 --- a/src/pages/app-profile.ts +++ b/src/pages/app-profile.ts @@ -132,7 +132,7 @@ export class AppProfile extends LitElement { background-size: cover; background-position: center; position: relative; - margin-top: 40px; + margin-top: calc(40px + env(safe-area-inset-top, 0px)); overflow: hidden; view-timeline-name: --banner-timeline; view-timeline-axis: block; @@ -1026,6 +1026,10 @@ export class AppProfile extends LitElement { // Store original state for rollback const originalFollowed = this.followed; + import('../utils/haptics').then(({ hapticImpact }) => + hapticImpact('light') + ); + await withOptimisticUpdate( // Apply optimistic update () => { @@ -1210,6 +1214,10 @@ export class AppProfile extends LitElement { // Store original state for rollback const originalFollowed = this.followed; + import('../utils/haptics').then(({ hapticImpact }) => + hapticImpact('light') + ); + await withOptimisticUpdate( // Apply optimistic update () => { diff --git a/src/pages/auth-callback.ts b/src/pages/auth-callback.ts new file mode 100644 index 00000000..55ee9dca --- /dev/null +++ b/src/pages/auth-callback.ts @@ -0,0 +1,96 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { msg, localized } from '@lit/localize'; + +const getRouter = () => import('../router/routes').then((m) => m.router); + +/** + * Lightweight callback page that handles the OAuth redirect. + * Parses `code` and `state` from query params, exchanges for a token, + * then navigates to the post-auth destination. + */ +@localized() +@customElement('auth-callback') +export class AuthCallback extends LitElement { + @state() private error: string | null = null; + + static styles = css` + :host { + display: flex; + align-items: center; + justify-content: center; + height: 100dvh; + width: 100%; + } + + .container { + text-align: center; + padding: 2rem; + } + + .error { + color: var(--md-sys-color-error, #b3261e); + } + + a { + color: var(--md-sys-color-primary, #6750a4); + } + `; + + connectedCallback() { + super.connectedCallback(); + this.handleCallback(); + } + + private async handleCallback() { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + const state = urlParams.get('state'); + + if (!code || !state) { + this.error = 'Missing authorization parameters.'; + return; + } + + try { + const { bootstrapSession } = await import('../services/auth-session.js'); + await bootstrapSession(); + + const { authToClient } = await import('../services/account.js'); + await authToClient(code, state); + + const router = await getRouter(); + await router.navigate(await this.getPostAuthRedirect()); + } catch (err) { + console.error('Auth callback error', err); + this.error = + err instanceof Error ? err.message : 'Authentication failed.'; + } + } + + private async getPostAuthRedirect(): Promise { + const { consumeAuthRedirect } = await import('../utils/auth-redirect.js'); + const storedRedirect = consumeAuthRedirect(); + if (storedRedirect) { + return storedRedirect; + } + return '/home'; + } + + render() { + if (this.error) { + return html` +
+

${this.error}

+ ${msg('Back to login')} +
+ `; + } + + return html` +
+

${msg('Signing in…')}

+
+ `; + } +} diff --git a/src/pages/conversation-thread.ts b/src/pages/conversation-thread.ts index 9916e18f..1c2e5f73 100644 --- a/src/pages/conversation-thread.ts +++ b/src/pages/conversation-thread.ts @@ -46,7 +46,7 @@ export class ConversationThread extends LitElement { display: flex; flex-direction: column; box-sizing: border-box; - padding-top: 46px; + padding-top: calc(46px + env(safe-area-inset-top, 0px)); } .scroller { diff --git a/src/pages/create-account.ts b/src/pages/create-account.ts index 7869b00e..ceb504e1 100644 --- a/src/pages/create-account.ts +++ b/src/pages/create-account.ts @@ -43,7 +43,7 @@ export class CreateAccount extends LitElement { main { padding: 10px; - padding-top: 60px; + padding-top: calc(60px + env(safe-area-inset-top, 0px)); overflow-y: auto; height: 88vh; diff --git a/src/pages/edit-page.ts b/src/pages/edit-page.ts index dd92bd7c..d681639a 100644 --- a/src/pages/edit-page.ts +++ b/src/pages/edit-page.ts @@ -19,7 +19,7 @@ export class EditPage extends LitElement { main { display: block; - padding-top: 56px; + padding-top: calc(56px + env(safe-area-inset-top, 0px)); padding-bottom: 80px; min-height: calc(100vh - 56px); max-width: var(--layout-max-width, 1200px); diff --git a/src/pages/post-detail.ts b/src/pages/post-detail.ts index f556e1e0..b170c3d7 100644 --- a/src/pages/post-detail.ts +++ b/src/pages/post-detail.ts @@ -74,7 +74,7 @@ export class PostDetail extends LitElement { /* Account for fixed header on full-page view */ :host(:not([embedded])) main { - padding-top: 60px; + padding-top: calc(60px + env(safe-area-inset-top, 0px)); } .scroller { @@ -159,6 +159,7 @@ export class PostDetail extends LitElement { padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); box-shadow: 0 -12px 24px rgba(0, 0, 0, 0.18); border-radius: var(--md-sys-shape-corner-medium); + margin-bottom: 68px; } .composer-shell { @@ -385,6 +386,15 @@ export class PostDetail extends LitElement { text: this.tweet?.content, url: this.tweet?.url, }); + } // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + else if (window.Capacitor) { + const { Share } = await import('@capacitor/share'); + await Share.share({ + title: 'Coho', + text: this.tweet?.content, + url: this.tweet?.url, + }); } else { // fallback to clipboard api await navigator.clipboard.writeText(this.tweet?.url || ''); @@ -528,7 +538,7 @@ export class PostDetail extends LitElement {
[] = [ title: 'login', render: () => html``, }, + { + path: '/auth/callback', + title: 'signing in', + plugins: [lazy(() => import('../pages/auth-callback.js'))], + render: () => html``, + }, { path: '/share', title: 'share', diff --git a/src/services/account.ts b/src/services/account.ts index b04fa4cf..2006c949 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -536,7 +536,13 @@ export const getInstanceInfo = async () => { export const initAuth = async (serverURL: string) => { const normalizedServer = normalizeServer(serverURL); - const redirect_uri = location.origin; + + // On native, location.origin is "https://localhost" (Capacitor WebView). + // The redirect must use the real domain so the OS intercepts it as an App Link. + const { isNativePlatform } = await import('../utils/platform.js'); + const redirect_uri = isNativePlatform() + ? 'https://coho.place/auth/callback' + : `${location.origin}/auth/callback`; const response = await fetch(`${FIREBASE_FUNCTIONS_BASE_URL}/authenticate`, { method: 'POST', @@ -549,7 +555,8 @@ export const initAuth = async (serverURL: string) => { const data = await response.json(); - window.location.href = data.url; + const { openOAuthUrl } = await import('./auth-platform.js'); + await openOAuthUrl(data.url); }; export const authToClient = async (code: string, state: string) => { diff --git a/src/services/ai.ts b/src/services/ai.ts index f654b5f4..46355d69 100644 --- a/src/services/ai.ts +++ b/src/services/ai.ts @@ -65,6 +65,57 @@ export const translate = async ( language: string = 'en-us', statusId?: string ) => { + // Normalize target language code (e.g., 'en-us' -> 'en') + const targetLanguage = language.split('-')[0].toLowerCase(); + + // Detect source language up front (shared by native + Chrome paths) + let sourceLanguage = 'en'; + if ('LanguageDetector' in window) { + try { + const detector = await window.LanguageDetector.create(); + const results = await detector.detect(prompt); + if (results && results.length > 0 && results[0].confidence > 0.5) { + sourceLanguage = results[0].detectedLanguage; + } + detector.destroy(); + } catch (err) { + console.warn('Language detection failed, defaulting to English:', err); + } + } else { + // Fallback to native ML Kit language detection on Android + try { + const { nativeDetectLanguage } = await import('./native-ai'); + sourceLanguage = await nativeDetectLanguage(prompt); + } catch { + // Not on Android or native detection failed + } + } + + // Skip translation if source and target are the same + if (sourceLanguage === targetLanguage) { + return prompt; + } + + // Try native Android ML Kit translation first + try { + const { getNativeAICapabilities, nativeTranslate } = + await import('./native-ai'); + const caps = await getNativeAICapabilities(); + if (caps.translation) { + const result = await nativeTranslate( + prompt, + sourceLanguage, + targetLanguage + ); + return result; + } + } catch (nativeErr) { + console.warn( + 'Native translation unavailable, trying Chrome API:', + nativeErr + ); + } + // Use Chrome's built-in Translator API try { // Check if the API is available @@ -72,31 +123,6 @@ export const translate = async ( throw new Error('Translator API not available'); } - // Detect source language using Language Detector API - let sourceLanguage = 'en'; - - if ('Translator' in window && 'LanguageDetector' in window) { - try { - console.log('Attempting language detection for prompt:', prompt); - const detector = await window.LanguageDetector.create(); - const results = await detector.detect(prompt); - console.log('Language detection results:', results); - if (results && results.length > 0 && results[0].confidence > 0.5) { - sourceLanguage = results[0].detectedLanguage; - } - detector.destroy(); - } catch (err) { - console.warn('Language detection failed, defaulting to English:', err); - } - } - - // Normalize language code (e.g., 'en-us' -> 'en') - const targetLanguage = language.split('-')[0].toLowerCase(); - - // Skip translation if source and target are the same - if (sourceLanguage === targetLanguage) { - return prompt; - } console.log( 'Checking translator capabilities for', sourceLanguage, @@ -181,6 +207,15 @@ export const createImage = async (prompt: string) => { * Check if Chrome's Proofreader API is available on this device */ export const isProofreaderAvailable = async (): Promise => { + // Check native Android Gemini Nano proofreading first + try { + const { getNativeAICapabilities } = await import('./native-ai'); + const caps = await getNativeAICapabilities(); + if (caps.proofreading) return true; + } catch { + // Not on native, fall through + } + try { if (typeof Proofreader === 'undefined') { return false; @@ -202,6 +237,22 @@ export const isProofreaderAvailable = async (): Promise => { export const proofread = async ( text: string ): Promise => { + // Try native Android Gemini Nano proofreading first + try { + const { getNativeAICapabilities, nativeProofread } = + await import('./native-ai'); + const caps = await getNativeAICapabilities(); + if (caps.proofreading) { + const result = await nativeProofread(text); + return result as ProofreadResult; + } + } catch (nativeErr) { + console.warn( + 'Native proofreading unavailable, trying Chrome API:', + nativeErr + ); + } + try { if (typeof Proofreader === 'undefined') { throw new Error('Proofreader API not available'); @@ -253,6 +304,49 @@ export const isPromptAPIAvailable = (): boolean => { export const generateAltText = async ( imageSource: string | Blob ): Promise => { + // Try native Android Gemini Nano first + try { + const { getNativeAICapabilities, nativeGenerateAltText } = + await import('./native-ai'); + const caps = await getNativeAICapabilities(); + if (caps.altText) { + // Convert image to base64 for the native bridge + let base64: string; + if (typeof imageSource === 'string' && !imageSource.startsWith('blob:')) { + // Regular URL — fetch and convert + const resp = await fetch(imageSource); + const blob = await resp.blob(); + const reader = new FileReader(); + base64 = await new Promise((resolve) => { + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + } else if (typeof imageSource === 'string') { + // blob: URL + const resp = await fetch(imageSource); + const blob = await resp.blob(); + const reader = new FileReader(); + base64 = await new Promise((resolve) => { + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + }); + } else { + const reader = new FileReader(); + base64 = await new Promise((resolve) => { + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(imageSource); + }); + } + const result = await nativeGenerateAltText(base64); + if (result) return result; + } + } catch (nativeErr) { + console.warn( + 'Native alt text generation unavailable, trying Chrome API:', + nativeErr + ); + } + try { if (!isPromptAPIAvailable()) { throw new Error('Prompt API (LanguageModel) not available'); @@ -479,6 +573,26 @@ What text is written in this image?`; * Check if on-device translation is available via Chrome's Translator API */ export const isOnDeviceTranslationAvailable = (): boolean => { + // Native Android ML Kit translation is always available on Capacitor Android + try { + // Sync check using Capacitor global (available when running in native shell) + const cap = + 'Capacitor' in window + ? ( + window as unknown as { + Capacitor?: { + isNativePlatform?: () => boolean; + getPlatform?: () => string; + }; + } + ).Capacitor + : undefined; + if (cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android') { + return true; + } + } catch { + // Not in Capacitor context + } return 'Translator' in window; }; diff --git a/src/services/auth-platform.ts b/src/services/auth-platform.ts new file mode 100644 index 00000000..f2444748 --- /dev/null +++ b/src/services/auth-platform.ts @@ -0,0 +1,128 @@ +import { isNativePlatform } from '../utils/platform.js'; + +/** + * Parsed OAuth callback parameters. + */ +export interface OAuthCallbackParams { + code: string; + state: string; +} + +/** + * Open the OAuth authorization URL in the appropriate context. + * + * - Web: navigates the current tab (`window.location.href`). + * - Capacitor: opens a Chrome Custom Tab / SFSafariViewController + * via `@capacitor/browser` so the user stays in a system browser + * context (cookie jar, password manager, etc.). + */ +export async function openOAuthUrl(url: string): Promise { + if (isNativePlatform()) { + const { Browser } = await import('@capacitor/browser'); + await Browser.open({ url, presentationStyle: 'popover' }); + } else { + window.location.href = url; + } +} + +/** + * Listen for an OAuth callback URL delivered via an App Link / Universal Link. + * + * Returns a promise that resolves with the parsed `code` and `state` when the + * OS hands a matching URL back to the app. The listener is automatically + * cleaned up after the first matching callback (or on `signal.abort()`). + * + * Only meaningful on Capacitor — on web the callback arrives via normal + * navigation to `/auth/callback` and is handled by the route component. + */ +export function waitForNativeCallback( + signal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + + let settled = false; + + Promise.all([import('@capacitor/app'), import('@capacitor/browser')]).then( + async ([{ App }, { Browser }]) => { + const appHandle = await App.addListener('appUrlOpen', ({ url }) => { + const params = parseCallbackUrl(url); + if (params && !settled) { + settled = true; + appHandle.remove(); + browserHandle.remove(); + Browser.close().catch(() => {}); + resolve(params); + } + }); + + // If the user closes the browser without completing OAuth, reject. + // A short delay avoids racing with appUrlOpen when an App Link + // redirect also triggers browserFinished. + const browserHandle = await Browser.addListener( + 'browserFinished', + () => { + setTimeout(() => { + if (!settled) { + settled = true; + appHandle.remove(); + browserHandle.remove(); + reject(new DOMException('Browser closed', 'AbortError')); + } + }, 500); + } + ); + + signal?.addEventListener( + 'abort', + () => { + if (!settled) { + settled = true; + appHandle.remove(); + browserHandle.remove(); + reject(new DOMException('Aborted', 'AbortError')); + } + }, + { once: true } + ); + } + ); + }); +} + +/** + * Check if the app was cold-launched via a deep link that carries OAuth + * callback parameters. Returns the params if present, otherwise `null`. + * + * Should be called once during app startup before the `appUrlOpen` listener + * is registered, because cold-launch URLs are only available via + * `App.getLaunchUrl()` and are NOT re-delivered as `appUrlOpen` events. + */ +export async function consumeLaunchCallback(): Promise { + if (!isNativePlatform()) return null; + + const { App } = await import('@capacitor/app'); + const result = await App.getLaunchUrl(); + if (!result?.url) return null; + return parseCallbackUrl(result.url); +} + +/** + * Parse an OAuth callback URL and extract `code` and `state` params. + * Returns `null` if the URL doesn't match the expected callback path. + */ +function parseCallbackUrl(url: string): OAuthCallbackParams | null { + try { + const parsed = new URL(url); + if (!parsed.pathname.startsWith('/auth/callback')) return null; + const code = parsed.searchParams.get('code'); + const state = parsed.searchParams.get('state'); + if (code && state) return { code, state }; + return null; + } catch { + return null; + } +} diff --git a/src/services/native-ai.ts b/src/services/native-ai.ts new file mode 100644 index 00000000..46d82def --- /dev/null +++ b/src/services/native-ai.ts @@ -0,0 +1,136 @@ +import { registerPlugin } from '@capacitor/core'; +import { isNativePlatform, getPlatform } from '../utils/platform.js'; + +/** + * TypeScript bridge to the native AiBridge Capacitor plugin. + * Provides on-device AI via ML Kit Translation and Gemini Nano + * (alt text generation + proofreading) on Android. + */ + +interface AiBridgePlugin { + translate(opts: { + text: string; + sourceLanguage: string; + targetLanguage: string; + }): Promise<{ translatedText: string }>; + + generateAltText(opts: { imageBase64: string }): Promise<{ altText: string }>; + + proofread(opts: { text: string }): Promise<{ + correctedInput: string; + corrections: Array<{ + startIndex: number; + endIndex: number; + correction: string; + correctionType?: string; + explanation?: string; + }>; + }>; + + checkAvailability(): Promise<{ + translation: boolean; + altText: boolean; + proofreading: boolean; + }>; + + detectLanguage(opts: { text: string }): Promise<{ language: string }>; +} + +// Cached capabilities – populated once per session +let cachedCapabilities: { + translation: boolean; + altText: boolean; + proofreading: boolean; +} | null = null; + +const bridge = registerPlugin('AiBridge'); + +function getBridge(): AiBridgePlugin { + return bridge; +} + +function isNativeAndroid(): boolean { + return isNativePlatform() && getPlatform() === 'android'; +} + +/** + * Probe which native AI capabilities are available on this device. + * Result is cached for the session lifetime. + */ +export async function getNativeAICapabilities(): Promise<{ + translation: boolean; + altText: boolean; + proofreading: boolean; +}> { + if (!isNativeAndroid()) { + return { translation: false, altText: false, proofreading: false }; + } + + if (cachedCapabilities) return cachedCapabilities; + + try { + cachedCapabilities = await getBridge().checkAvailability(); + } catch { + cachedCapabilities = { + translation: false, + altText: false, + proofreading: false, + }; + } + + return cachedCapabilities; +} + +/** + * Translate text using ML Kit on-device translation. + * Throws if not on native Android or if translation fails. + */ +export async function nativeTranslate( + text: string, + sourceLanguage: string, + targetLanguage: string +): Promise { + const result = await getBridge().translate({ + text, + sourceLanguage, + targetLanguage, + }); + return result.translatedText; +} + +/** + * Generate alt text for an image using Gemini Nano. + * @param imageBase64 - Base64-encoded image data (with or without data URI prefix) + */ +export async function nativeGenerateAltText( + imageBase64: string +): Promise { + const result = await getBridge().generateAltText({ imageBase64 }); + return result.altText; +} + +/** + * Proofread text using Gemini Nano. + * Returns a result matching the ProofreadResult interface shape. + */ +export async function nativeProofread(text: string): Promise<{ + correctedInput: string; + corrections: Array<{ + startIndex: number; + endIndex: number; + correction: string; + correctionType?: string; + explanation?: string; + }>; +}> { + return await getBridge().proofread({ text }); +} + +/** + * Detect the language of text using ML Kit Language Identification. + * Returns a BCP-47 language code (e.g. 'en', 'ja', 'es'). + */ +export async function nativeDetectLanguage(text: string): Promise { + const result = await getBridge().detectLanguage({ text }); + return result.language; +} diff --git a/src/services/native-share-target.ts b/src/services/native-share-target.ts new file mode 100644 index 00000000..63932e68 --- /dev/null +++ b/src/services/native-share-target.ts @@ -0,0 +1,116 @@ +import { Capacitor, registerPlugin } from '@capacitor/core'; + +interface SharedFile { + name: string; + type: string; + path: string; + size: number; +} + +interface SharedContent { + hasShare: boolean; + text?: string; + subject?: string; + files?: SharedFile[]; +} + +interface ShareTargetBridgePlugin { + getSharedContent(): Promise; + clearSharedContent(): Promise; + addListener( + event: 'shareIntent', + callback: () => void + ): Promise<{ remove: () => void }>; +} + +const ShareTargetBridge = + registerPlugin('ShareTargetBridge'); + +export interface NativeShareResult { + hasShare: boolean; + text?: string; + subject?: string; + /** Name of the file written to the shareTarget cache (for media shares). */ + cachedFileName?: string; +} + +/** + * Check for shared content from an Android ACTION_SEND intent. + * If media files were shared, they are written into the Cache API using + * the same key format as the PWA share target (`/_share/{name}`), + * so the existing post-dialog shareTarget flow can pick them up. + */ +export async function checkNativeShare(): Promise { + if (!Capacitor.isNativePlatform()) { + return { hasShare: false }; + } + + const shared = await ShareTargetBridge.getSharedContent(); + if (!shared.hasShare) { + return { hasShare: false }; + } + + const result: NativeShareResult = { + hasShare: true, + text: shared.text, + subject: shared.subject, + }; + + // If media files were shared, write the first one into the Cache API + // so the existing compose-dialog media flow can handle it. + if (shared.files && shared.files.length > 0) { + const file = shared.files[0]; + const webPath = Capacitor.convertFileSrc(file.path); + + const response = await fetch(webPath); + const blob = await response.blob(); + + const cache = await caches.open('shareTarget'); + const cacheKey = `/_share/${encodeURIComponent(file.name)}`; + await cache.put( + cacheKey, + new Response(blob, { + headers: { + 'content-length': file.size.toString(), + 'content-type': file.type, + }, + }) + ); + + result.cachedFileName = file.name; + } + + // Clear the intent so it doesn't re-trigger on next resume + await ShareTargetBridge.clearSharedContent(); + + return result; +} + +/** + * Listen for share intents that arrive while the app is already open + * (warm start via onNewIntent). Calls the handler with the shared content. + */ +export function onNativeShareIntent( + handler: (result: NativeShareResult) => void +): { remove: () => void } { + if (!Capacitor.isNativePlatform()) { + return { remove: () => {} }; + } + + let listenerHandle: { remove: () => void } | null = null; + + ShareTargetBridge.addListener('shareIntent', async () => { + const result = await checkNativeShare(); + if (result.hasShare) { + handler(result); + } + }).then((handle) => { + listenerHandle = handle; + }); + + return { + remove: () => { + listenerHandle?.remove(); + }, + }; +} diff --git a/src/services/notifications.ts b/src/services/notifications.ts index 7c9b0908..d5dcaa35 100644 --- a/src/services/notifications.ts +++ b/src/services/notifications.ts @@ -42,6 +42,13 @@ function urlBase64ToUint8Array(key: string) { } export const subToPush = async () => { + // On native Capacitor platforms, delegate to the FCM-based flow + const { isNativePlatform } = await import('../utils/platform.js'); + if (isNativePlatform()) { + const { subToPushNative } = await import('./push-native.js'); + return subToPushNative(); + } + const registration = await navigator.serviceWorker.getRegistration(); let vapidKey: string | undefined; @@ -264,6 +271,13 @@ export const modifyPush = async (options: { }; export const unsubToPush = async () => { + // On native Capacitor platforms, delegate to the FCM-based flow + const { isNativePlatform } = await import('../utils/platform.js'); + if (isNativePlatform()) { + const { unsubToPushNative } = await import('./push-native.js'); + return unsubToPushNative(); + } + // get push subscription const registration = await navigator.serviceWorker.getRegistration(); const subscription = await registration?.pushManager.getSubscription(); diff --git a/src/services/push-native.ts b/src/services/push-native.ts new file mode 100644 index 00000000..6a77fc0c --- /dev/null +++ b/src/services/push-native.ts @@ -0,0 +1,419 @@ +/** + * Native Push Notifications (Capacitor Android) + * + * Uses @capacitor/push-notifications for FCM token management and a + * Firebase Function relay to bridge Mastodon's Web Push to FCM. + * + * The relay presents a standard Web Push endpoint to Mastodon, so the + * Mastodon API call is identical to the PWA flow — only the subscription + * source differs. + */ + +import { PushNotifications } from '@capacitor/push-notifications'; +import { getClientConfig } from '../mastodon/config/client'; + +// --------------------------------------------------------------------------- +// Relay Configuration +// --------------------------------------------------------------------------- + +/** + * Base URL for the push relay Firebase Functions. + * In production this points to the deployed Cloud Function; during local + * development you can override via localStorage for testing with the emulator. + */ +function getRelayBaseUrl(): string { + return ( + localStorage.getItem('pushRelayBaseUrl') || + 'https://us-central1-coho-mastodon.cloudfunctions.net' + ); +} + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +const RELAY_REGISTRATION_ID_KEY = 'pushRelayRegistrationId'; +const FCM_TOKEN_KEY = 'pushRelayFcmToken'; + +// --------------------------------------------------------------------------- +// Subscribe +// --------------------------------------------------------------------------- + +/** + * Subscribe to push notifications on a native Capacitor platform. + * + * 1. Requests notification permission + * 2. Gets an FCM token from the OS + * 3. Registers with the push relay → gets a Web Push–compatible endpoint + * 4. Sends that endpoint to Mastodon via POST /api/v1/push/subscription + */ +export async function subToPushNative(): Promise { + console.log('[NativePush] Starting subscription...'); + + // 1. Request permission + let permResult = await PushNotifications.checkPermissions(); + console.log('[NativePush] Permission status:', permResult.receive); + + if (permResult.receive === 'prompt') { + permResult = await PushNotifications.requestPermissions(); + console.log('[NativePush] Permission after request:', permResult.receive); + } + if (permResult.receive !== 'granted') { + throw new Error('Push notification permission denied'); + } + + // 2. Register with the OS and get the FCM token. + // Await the addListener calls so Capacitor has fully wired them + // before we call register(). + const fcmToken = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + console.error('[NativePush] FCM registration timed out after 15s'); + reject(new Error('FCM registration timed out')); + }, 15_000); + + let settled = false; + + const setup = async () => { + await PushNotifications.addListener('registration', (token) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + console.log('[NativePush] FCM token received'); + resolve(token.value); + }); + + await PushNotifications.addListener('registrationError', (err) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + console.error('[NativePush] FCM registration error:', err); + reject(new Error(err.error || 'FCM registration failed')); + }); + + console.log('[NativePush] Listeners ready, calling register()...'); + await PushNotifications.register(); + console.log('[NativePush] register() returned'); + }; + + setup().catch((err) => { + if (!settled) { + settled = true; + clearTimeout(timeout); + reject(err); + } + }); + }); + + localStorage.setItem(FCM_TOKEN_KEY, fcmToken); + console.log('[NativePush] FCM token stored, registering with relay...'); + + // 3. Register with the relay + const relayUrl = `${getRelayBaseUrl()}/pushRelay`; + console.log('[NativePush] Registering with relay...'); + + let relayResponse: Response; + try { + relayResponse = await fetch(relayUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'register', fcmToken }), + }); + } catch (fetchErr) { + console.error('[NativePush] Relay fetch failed:', fetchErr); + throw fetchErr; + } + + console.log('[NativePush] Relay response status:', relayResponse.status); + + if (!relayResponse.ok) { + const errBody = await relayResponse.text().catch(() => ''); + console.error('[NativePush] Relay error body:', errBody); + throw new Error(`Push relay registration failed: ${relayResponse.status}`); + } + + const relay: { + registrationId: string; + endpoint: string; + keys: { p256dh: string; auth: string }; + } = await relayResponse.json(); + + localStorage.setItem(RELAY_REGISTRATION_ID_KEY, relay.registrationId); + + // 4. Register the Web Push–compatible subscription with Mastodon + const { url, accessToken } = getClientConfig(); + + const mastodonResponse = await fetch( + `https://${url}/api/v1/push/subscription`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + subscription: { + endpoint: relay.endpoint, + keys: { + p256dh: relay.keys.p256dh, + auth: relay.keys.auth, + }, + }, + data: { + alerts: { + follow: true, + reblog: true, + favourite: true, + mention: true, + poll: true, + follow_request: true, + status: true, + update: true, + }, + policy: 'all', + }, + }), + } + ); + + if (!mastodonResponse.ok) { + throw new Error( + `Mastodon push subscription failed: ${mastodonResponse.status}` + ); + } + + console.log('[NativePush] Subscribed via relay', relay.registrationId); +} + +// --------------------------------------------------------------------------- +// Unsubscribe +// --------------------------------------------------------------------------- + +export async function unsubToPushNative(): Promise { + const registrationId = localStorage.getItem(RELAY_REGISTRATION_ID_KEY); + const { url, accessToken } = getClientConfig(); + + // Delete from Mastodon + try { + await fetch(`https://${url}/api/v1/push/subscription`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + } catch { + console.log('[NativePush] Mastodon unsub failed (may already be gone)'); + } + + // Delete from relay + if (registrationId) { + try { + await fetch(`${getRelayBaseUrl()}/pushRelay`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'unregister', + registrationId, + }), + }); + } catch { + console.log('[NativePush] Relay unsub failed'); + } + localStorage.removeItem(RELAY_REGISTRATION_ID_KEY); + } + + localStorage.removeItem(FCM_TOKEN_KEY); + + await PushNotifications.removeAllListeners(); + console.log('[NativePush] Unsubscribed'); +} + +// --------------------------------------------------------------------------- +// Check subscription status +// --------------------------------------------------------------------------- + +export function isNativePushSubscribed(): boolean { + return !!localStorage.getItem(RELAY_REGISTRATION_ID_KEY); +} + +// --------------------------------------------------------------------------- +// Notification target URL routing (mirrors src/sw/notifications.ts) +// --------------------------------------------------------------------------- + +function getTargetUrl(data: Record): string { + if (!data?.notification_type || !data?.notification_id) { + return '/home?tab=notifications'; + } + + switch (data.notification_type) { + case 'mention': + case 'reblog': + case 'favourite': + case 'poll': + case 'status': + case 'update': + return `/post/notification?notification_id=${data.notification_id}`; + case 'follow': + case 'follow_request': + case 'admin.sign_up': + case 'admin.report': + return '/home?tab=notifications'; + default: + return '/home?tab=notifications'; + } +} + +// --------------------------------------------------------------------------- +// Notification Channels (Android 8+) +// --------------------------------------------------------------------------- + +const NOTIFICATION_CHANNELS: Array<{ + id: string; + name: string; + description: string; + importance: 1 | 2 | 3 | 4 | 5; +}> = [ + { + id: 'coho_mentions', + name: 'Mentions', + description: 'When someone mentions you', + importance: 4, + }, + { + id: 'coho_boosts', + name: 'Boosts', + description: 'When someone boosts your post', + importance: 3, + }, + { + id: 'coho_favourites', + name: 'Favourites', + description: 'When someone favourites your post', + importance: 2, + }, + { + id: 'coho_follows', + name: 'Follows', + description: 'New followers and follow requests', + importance: 3, + }, + { + id: 'coho_polls', + name: 'Polls', + description: 'Poll results', + importance: 2, + }, + { + id: 'coho_status', + name: 'New Posts', + description: 'Posts from people you follow', + importance: 2, + }, + { + id: 'coho_general', + name: 'General', + description: 'Other notifications', + importance: 3, + }, +]; + +/** + * Create per-type notification channels. Idempotent — Android ignores + * duplicate creates but preserves user-customised settings. + */ +async function ensureNotificationChannels(): Promise { + for (const ch of NOTIFICATION_CHANNELS) { + await PushNotifications.createChannel(ch); + } +} + +// --------------------------------------------------------------------------- +// Setup Listeners (call once at app startup on native) +// --------------------------------------------------------------------------- + +/** + * Attach listeners for incoming FCM push notifications and user taps. + * Also handles FCM token refresh by re-registering with the relay. + */ +export function setupNativePushListeners(): void { + // Create channels early so they exist before any notification arrives + ensureNotificationChannels().catch((err) => + console.warn('[NativePush] Channel creation failed:', err) + ); + // Foreground notification received + PushNotifications.addListener('pushNotificationReceived', (notification) => { + console.log('[NativePush] Foreground notification received'); + + const data = notification.data as Record | undefined; + if (!data) return; + + // Broadcast to the app — same message shape the service worker uses + // so components like conversation-thread can react + window.postMessage( + { + type: 'push-notification', + notificationType: data.notification_type, + notificationId: data.notification_id, + }, + '*' + ); + }); + + // User tapped a notification + PushNotifications.addListener( + 'pushNotificationActionPerformed', + async (action) => { + console.log('[NativePush] Notification tap'); + + const data = action.notification.data as + | Record + | undefined; + const targetUrl = getTargetUrl(data || {}); + + // Lazy-import router to avoid circular dependencies + const { router } = await import('../router/routes'); + router.navigate(targetUrl); + } + ); + + // FCM token refresh — re-register with relay + update Mastodon subscription. + // Only acts when the user already has an active relay registration AND the + // token actually changed (guards against the initial registration event + // firing into this listener during subToPushNative). + PushNotifications.addListener('registration', async (token) => { + const previousToken = localStorage.getItem(FCM_TOKEN_KEY); + const registrationId = localStorage.getItem(RELAY_REGISTRATION_ID_KEY); + + // Skip if this is the initial registration (no relay yet) or token unchanged + if (!registrationId || token.value === previousToken) return; + + console.log('[NativePush] FCM token refreshed, re-registering'); + + // Clean up old relay registration + if (registrationId) { + try { + await fetch(`${getRelayBaseUrl()}/pushRelay`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'unregister', + registrationId, + }), + }); + } catch { + // Best effort + } + localStorage.removeItem(RELAY_REGISTRATION_ID_KEY); + } + + // Re-subscribe with the new token + try { + await subToPushNative(); + } catch (err) { + console.error( + '[NativePush] Re-registration after token refresh failed', + err + ); + } + }); +} diff --git a/src/services/settings.ts b/src/services/settings.ts index fd36eb38..4fad3448 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -5,6 +5,7 @@ export interface Settings { wellness?: boolean; focus?: boolean; sensitive?: boolean; + haptics?: boolean; } const defaultSettings = { @@ -14,6 +15,7 @@ const defaultSettings = { wellness: false, focus: false, sensitive: false, + haptics: true, }; export async function getSettings(): Promise { @@ -41,6 +43,9 @@ export async function setSettings(settings: Settings) { sensitive: Object.keys(settings).includes('sensitive') ? settings.sensitive : currentSettings.sensitive, + haptics: Object.keys(settings).includes('haptics') + ? settings.haptics + : currentSettings.haptics, }; // Also store theme color in localStorage for instant access on page load diff --git a/src/services/wear-sync.ts b/src/services/wear-sync.ts new file mode 100644 index 00000000..56d3ace1 --- /dev/null +++ b/src/services/wear-sync.ts @@ -0,0 +1,30 @@ +/** + * Pushes auth credentials to a paired Wear OS watch via the + * WearSyncBridge Capacitor plugin so the watch can independently + * call the Mastodon API. + * + * Returns true if sync succeeded, false otherwise. + */ +export async function syncCredentialsToWearOS(): Promise { + try { + const { isNativePlatform } = await import('../utils/platform.js'); + if (!isNativePlatform()) return false; + + const { registerPlugin } = await import('@capacitor/core'); + const WearSyncBridge = registerPlugin('WearSyncBridge'); + const server = localStorage.getItem('server') || ''; + const accessToken = localStorage.getItem('accessToken') || ''; + const acct = localStorage.getItem('acct') || ''; + if (!server || !accessToken) return false; + + await (WearSyncBridge as any).syncCredentials({ + server, + accessToken, + acct, + }); + return true; + } catch { + // Wear sync bridge not available + return false; + } +} diff --git a/src/styles/home-styles.ts b/src/styles/home-styles.ts index 907a54b9..2903d466 100644 --- a/src/styles/home-styles.ts +++ b/src/styles/home-styles.ts @@ -270,7 +270,8 @@ export const homeStyles = css` } main { - padding-top: 54px; + padding-top: calc(54px + env(safe-area-inset-top, 0px)); + display: grid; grid-template-columns: var(--layout-nav-width, 80px) 1fr var( --layout-sidebar-width, @@ -549,6 +550,10 @@ export const homeStyles = css` display: none; } + otter-drawer::part(base) { + margin-top: calc(env(safe-area-inset-top, 0px)); + } + md-tab-panel { max-width: unset; } @@ -580,6 +585,7 @@ export const homeStyles = css` #mobile-actions { display: flex; + bottom: calc(env(safe-area-inset-bottom, 0px) + 86px); } #mobile-actions md-button md-icon { @@ -611,7 +617,7 @@ export const homeStyles = css` height: 100%; overflow-y: auto; -webkit-overflow-scrolling: touch; - padding-top: 50px; + padding-top: calc(50px + env(safe-area-inset-top, 0px)); scrollbar-color: var(--md-sys-scrollbar-thumb-color) var(--md-sys-color-background); } diff --git a/src/styles/shared-styles.ts b/src/styles/shared-styles.ts index 227ece08..1d273f5d 100644 --- a/src/styles/shared-styles.ts +++ b/src/styles/shared-styles.ts @@ -10,7 +10,7 @@ export const styles = css` } main { - padding-top: 60px; + padding-top: calc(60px + env(safe-area-inset-top, 0px)); } md-button::part(control) { diff --git a/src/utils/dynamic-theme.ts b/src/utils/dynamic-theme.ts new file mode 100644 index 00000000..0322ba70 --- /dev/null +++ b/src/utils/dynamic-theme.ts @@ -0,0 +1,25 @@ +import { isNativePlatform, getPlatform } from './platform'; + +interface DynamicThemePlugin { + getAccentColor(): Promise<{ color: string | null; supported: boolean }>; +} + +/** + * Returns the Android Material You dynamic accent color as a hex string, + * or null when running as a PWA or on unsupported Android versions (< 12). + */ +export async function getAndroidDynamicColor(): Promise { + if (!isNativePlatform() || getPlatform() !== 'android') { + return null; + } + + try { + const { registerPlugin } = await import('@capacitor/core'); + const DynamicThemeBridge = + registerPlugin('DynamicThemeBridge'); + const result = await DynamicThemeBridge.getAccentColor(); + return result.supported ? result.color : null; + } catch { + return null; + } +} diff --git a/src/utils/handle-vibrate.ts b/src/utils/handle-vibrate.ts index 250f9c0e..8d4a8a96 100644 --- a/src/utils/handle-vibrate.ts +++ b/src/utils/handle-vibrate.ts @@ -1,13 +1,3 @@ -export function enableVibrate(root: ShadowRoot) { - // find all md-button elements in the shadow root - const buttons = root.querySelectorAll('md-button'); - const slButtons = root.querySelectorAll('sl-button'); - - // add a click event listener to each button - [...buttons, ...slButtons].forEach((button) => { - button.addEventListener('click', () => { - // vibrate for 10ms - navigator.vibrate(10); - }); - }); -} +// This file is intentionally empty. +// Haptics are now handled by src/utils/haptics.ts. +// This file can be safely deleted. diff --git a/src/utils/haptics.ts b/src/utils/haptics.ts new file mode 100644 index 00000000..8777ae0e --- /dev/null +++ b/src/utils/haptics.ts @@ -0,0 +1,103 @@ +import { Capacitor } from '@capacitor/core'; + +type ImpactStyle = 'light' | 'medium' | 'heavy'; +type NotificationType = 'success' | 'warning' | 'error'; + +let _hapticsEnabled: boolean | null = null; + +async function isEnabled(): Promise { + if (_hapticsEnabled !== null) return _hapticsEnabled; + try { + const { getSettings } = await import('../services/settings'); + const settings = await getSettings(); + _hapticsEnabled = settings.haptics !== false; + } catch { + _hapticsEnabled = true; + } + return _hapticsEnabled; +} + +/** Call after the user changes the haptics setting to update the cache. */ +export function setHapticsEnabled(enabled: boolean) { + _hapticsEnabled = enabled; +} + +const vibrateDurations: Record = { + light: 10, + medium: 20, + heavy: 30, +}; + +const notificationDurations: Record = { + success: 15, + warning: 20, + error: 30, +}; + +/** + * Trigger an impact haptic. Use for discrete user actions like tapping + * a like/boost/bookmark button. + */ +export async function hapticImpact(style: ImpactStyle = 'light') { + try { + if (!(await isEnabled())) return; + + if (Capacitor.isNativePlatform()) { + const { Haptics, ImpactStyle } = await import('@capacitor/haptics'); + const map: Record = { + light: ImpactStyle.Light, + medium: ImpactStyle.Medium, + heavy: ImpactStyle.Heavy, + }; + await Haptics.impact({ style: map[style] }); + } else if (navigator.vibrate) { + navigator.vibrate(vibrateDurations[style]); + } + } catch { + // Haptics should never throw or block the UI + } +} + +/** + * Trigger a notification haptic. Use for outcome feedback like + * successfully publishing a post. + */ +export async function hapticNotification(type: NotificationType = 'success') { + try { + if (!(await isEnabled())) return; + + if (Capacitor.isNativePlatform()) { + const { Haptics, NotificationType } = await import('@capacitor/haptics'); + const map: Record = + { + success: NotificationType.Success, + warning: NotificationType.Warning, + error: NotificationType.Error, + }; + await Haptics.notification({ type: map[type] }); + } else if (navigator.vibrate) { + navigator.vibrate(notificationDurations[type]); + } + } catch { + // Haptics should never throw or block the UI + } +} + +/** + * Trigger a light selection tick. Use for selection changes or + * pull-to-refresh thresholds. + */ +export async function hapticSelection() { + try { + if (!(await isEnabled())) return; + + if (Capacitor.isNativePlatform()) { + const { Haptics } = await import('@capacitor/haptics'); + await Haptics.selectionChanged(); + } else if (navigator.vibrate) { + navigator.vibrate(5); + } + } catch { + // Haptics should never throw or block the UI + } +} diff --git a/src/utils/platform.ts b/src/utils/platform.ts new file mode 100644 index 00000000..45b4acde --- /dev/null +++ b/src/utils/platform.ts @@ -0,0 +1,16 @@ +import { Capacitor } from '@capacitor/core'; + +/** + * Returns true when the app is running inside a Capacitor native shell + * (Android or iOS), false when running as a regular web page or PWA. + */ +export function isNativePlatform(): boolean { + return Capacitor.isNativePlatform(); +} + +/** + * Returns the current platform: 'android', 'ios', or 'web'. + */ +export function getPlatform(): 'android' | 'ios' | 'web' { + return Capacitor.getPlatform() as 'android' | 'ios' | 'web'; +} diff --git a/src/utils/timeline-actions.ts b/src/utils/timeline-actions.ts index fd025599..6de8f9d3 100644 --- a/src/utils/timeline-actions.ts +++ b/src/utils/timeline-actions.ts @@ -23,6 +23,15 @@ export async function shareStatus(tweet: Post | undefined | null) { // User cancelled or share failed, ignore console.warn('Share failed:', err); } + } // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + else if (window.Capacitor) { + const { Share } = await import('@capacitor/share'); + await Share.share({ + title: 'Coho', + text: content, + url: url, + }); } else { try { await navigator.clipboard.writeText(url); @@ -64,6 +73,8 @@ export async function toggleStatusAction(options: ToggleOptions) { return; } + import('./haptics').then(({ hapticImpact }) => hapticImpact('light')); + if (isActive) { // UNDO await withOptimisticUpdate( @@ -96,6 +107,8 @@ export async function performOneWayAction( return; } + import('./haptics').then(({ hapticImpact }) => hapticImpact('light')); + await withOptimisticUpdate(onOptimisticUpdate, apiCall, onRollback, { errorMessage, }); diff --git a/tests/services/settings.test.ts b/tests/services/settings.test.ts index 50f19a29..dd2cd7ca 100644 --- a/tests/services/settings.test.ts +++ b/tests/services/settings.test.ts @@ -118,6 +118,7 @@ describe('settings service', () => { wellness: true, focus: true, sensitive: true, + haptics: true, }; await setSettings(fullSettings); diff --git a/vite.config.ts b/vite.config.ts index f24898c9..0011448c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -179,7 +179,7 @@ customPlugins.push({ handler(html: string, { bundle }: { bundle?: Record }) { if (!bundle) return html; - const chunksToPreload = ['vendor-idb-keyval', 'preload-']; + const chunksToPreload = ['vendor-idb-keyval']; const preloadLinks: string[] = []; for (const chunkPattern of chunksToPreload) { @@ -250,8 +250,9 @@ export default defineConfig({ terserOptions: { module: true, compress: { - drop_console: true, + drop_console: false, drop_debugger: true, + pure_funcs: ['console.log'], // Strip console.log but keep warn/error passes: 2, arrows: true, booleans_as_integers: true, diff --git a/xliff/de.xlf b/xliff/de.xlf index e5fc1d12..28aa788d 100644 --- a/xliff/de.xlf +++ b/xliff/de.xlf @@ -1898,6 +1898,42 @@ Current account avatar + + Synced to watch + + + No credentials to sync + + + Failed to sync to watch + + + Sync to Watch + + + Synced ✓ + + + Syncing… + + + Sync + + + Send your account credentials to your Wear OS watch. + + + Back to login + + + Signing in… + + + Haptic Feedback + + + Vibrate on actions like likes, boosts, and publishing. + diff --git a/xliff/es.xlf b/xliff/es.xlf index e96658d6..a3d11103 100644 --- a/xliff/es.xlf +++ b/xliff/es.xlf @@ -1898,6 +1898,42 @@ Current account avatar + + Synced to watch + + + No credentials to sync + + + Failed to sync to watch + + + Sync to Watch + + + Synced ✓ + + + Syncing… + + + Sync + + + Send your account credentials to your Wear OS watch. + + + Back to login + + + Signing in… + + + Haptic Feedback + + + Vibrate on actions like likes, boosts, and publishing. +