diff --git a/README.md b/README.md index ed38bf06..8921eda8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ -**Read this in other languages:** [Русский](README_RU.md), [한국어](README_KOR.md), [اُردو](README_UR.md), [Español](README_ES.md) +**Read this in other languages:** [Русский](README_RU.md), [Türkçe](README_TR.md), [한국어](README_KOR.md), [اُردو](README_UR.md), [Español](README_ES.md) --- diff --git a/README_ES.md b/README_ES.md index 58e33ae5..f7945627 100644 --- a/README_ES.md +++ b/README_ES.md @@ -23,7 +23,7 @@ **Si deseas leer este documento en otro lenguaje:** [Русский](README_RU.md), -[한국어](README_KOR.md), [اُردو](README_UR.md), [English](README.md) +[Türkçe](README_TR.md), [한국어](README_KOR.md), [اُردو](README_UR.md), [English](README.md) --- diff --git a/README_KOR.md b/README_KOR.md index 85f653ae..c9f2d748 100644 --- a/README_KOR.md +++ b/README_KOR.md @@ -22,7 +22,7 @@ -**다른 언어로 읽기:** [English](README.md), [Русский](README_RU.md), [Español](README_ES.md) +**다른 언어로 읽기:** [English](README.md), [Русский](README_RU.md), [Türkçe](README_TR.md), [Español](README_ES.md) --- diff --git a/README_RU.md b/README_RU.md index 8d5552cd..8e845b68 100644 --- a/README_RU.md +++ b/README_RU.md @@ -22,7 +22,7 @@ -**Читать на других языках:** [English](README.md), [한국어](README_KOR.md), [اُردو](README_UR.md), +**Читать на других языках:** [English](README.md), [Türkçe](README_TR.md), [한국어](README_KOR.md), [اُردو](README_UR.md), [Español](README_ES.md) --- diff --git a/README_TR.md b/README_TR.md new file mode 100644 index 00000000..72b57e65 --- /dev/null +++ b/README_TR.md @@ -0,0 +1,215 @@ +

+
+ MonoGram + +
+ MonoGram +
+

+ +

+ + + + + + + + + + + + +

+ +**Bu dokümanı diğer dillerde okuyun:** [English](README.md), [Русский](README_RU.md), [Türkçe](README_TR.md), [한국어](README_KOR.md), [اُردو](README_UR.md), [Español](README_ES.md) + +--- + +**MonoGram**, Android için modern, yıldırım hızında ve zarif bir resmi olmayan Telegram istemcisidir. **Jetpack Compose** ve **Material Design 3** ile sıfırdan inşa edilen uygulama, resmi **TDLib** altyapısıyla desteklenen yerleşik (native) ve akıcı bir deneyim sunar. + +> [!IMPORTANT] +> MonoGram şu an **aktif geliştirme** aşamasındadır. Sık güncellemeler, mimari değişiklikler ve nadiren de olsa hatalar (bug) bekleyebilirsiniz. + +Projeyi [**Boosty**](https://boosty.to/monogram) üzerinden destekleyebilirsiniz. + +--- + +## Ekran Görüntüleri + +
+ +| | | | | +|:---:|:---:|:---:|:---:| +| Ekran Görüntüsü 1 | Ekran Görüntüsü 2 | Ekran Görüntüsü 3 | Ekran Görüntüsü 4 | + +
+ +--- + +## Öne Çıkan Özellikler + +- **Bağımsız İstemci** — Android için Telegram'ın bir çatalı (fork) değildir. MonoGram, tamamen sıfırdan bağımsız bir proje olarak inşa edilmiştir. +- **Material Design 3** — Telefonlar, tabletler ve katlanabilir cihazlarda harika görünen, estetik ve uyarlanabilir kullanıcı arayüzü. +- **Güvenli** — Yerleşik biyometrik kilitleme ve şifrelenmiş yerel depolama. +- **Zengin Medya Deneyimi** — ExoPlayer ve Coil 3 ile yüksek performanslı medya oynatma. +- **Hızlı ve Verimli** — Kotlin Coroutines ile desteklenen, performans için optimize edilmiş yapı. +- **Temiz Mimari (Clean Architecture)** — Domain, Data ve Presentation katmanları ile sorumlulukların net bir şekilde ayrılması. +- **MVI Deseni** — MVIKotlin kullanılarak sağlanan öngörülebilir durum yönetimi. +- **NFT veya Kripto Yok** — MonoGram; Telegram tarafından dayatılan ve bir mesajlaşma uygulamasının kapsamı dışında gördüğümüz NFT tanıtımları, hediyeleri veya benzeri özellikleri asla içermeyecektir. + +--- + +## Başlangıç + +Projeyi yerel ortamınızda kurmak için bu adımları izleyin. + +### Ön Koşullar + +- **Android Studio**: Ladybug veya daha yeni bir sürüm (önerilir). +- **JDK**: Java 17 veya daha yeni bir sürüm. + +### 1. Depoyu Klonlayın + +```bash +git clone --recurse-submodules https://github.com/monogram-android/monogram.git +cd monogram +``` +### 2. Telegram API Anahtarlarını Yapılandırın + +Telegram sunucularına bağlanmak için kendi API kimlik bilgilerinize ihtiyacınız vardır. + +1. [my.telegram.org](https://my.telegram.org/) adresinde oturum açın. +2. **API development tools (API geliştirme araçları)** bölümüne gidin. +3. `App api_id` ve `App api_hash` değerlerinizi almak için yeni bir uygulama oluşturun. +4. Projenin kök dizininde (eğer yoksa) `local.properties` adlı bir dosya oluşturun. +5. Aşağıdaki satırları dosyaya ekleyin: + +```properties +API_ID=12345678 +API_HASH=your_api_hash_here +``` +### 3. Anlık Bildirimleri (Push Notifications) Yapılandırın + +1. [Firebase konsolunda](https://console.firebase.google.com) oturum açın. +2. Yeni bir proje oluşturun. +3. İhtiyacınız olan `applicationId` değerine sahip yeni bir uygulama ekleyin. Farklı ID'lere sahip birden fazla uygulamanız varsa, birden fazla Firebase uygulaması oluşturun. **Varsayılan olarak, hata ayıklama (debug) ve yayınlama (release) sürümlerinin `applicationId` değerleri farklıdır.** +4. `google-services.json` dosyasını indirin ve **app** modülünün kök dizinine kopyalayın (`monogram/app/google-services.json`). Birden fazla uygulama oluşturduysanız, yalnızca en güncel yapılandırmayı kopyalayın. +5. **Cloud Messaging** bölümüne gidin. +6. **Manage service accounts** (Hizmet hesaplarını yönet) seçeneğine tıklayın. +7. Açılan pencerenin üst kısmındaki **Keys** (Anahtarlar) sekmesini seçin. +8. **Add key** (Anahtar ekle) seçeneğine tıklayın ve **JSON** opsiyonunu seçin. Dosyanın indirilmesini bekleyin. +9. Uygulama ID'nizi aldığınız Telegram API sayfasına geri dönün. +10. FCM kimlik bilgileri (FCM credentials) bölümünün yanındaki **Update** (Güncelle) butonuna tıklayın. +11. Açılan sayfada hizmet hesabı (service account) JSON dosyasını yükleyin. + +### 4. İlk Kurulum: libvpx Derlemesi + +Animasyonların çalışması için libvpx'in derlenmiş olması gerekir. Bu işlem, Gradle derlemesini başlatmadan önce yapılmalıdır; aksi takdirde derleme hatalarıyla karşılaşırsınız. + +1. Çalışma dizininizi `presentation/src/main/cpp` olarak değiştirin. +2. `build.sh` dosyası içerisine kendi `ANDROID_NDK_HOME` yolunuzu ekleyin. +3. `build.sh` dosyasını çalıştırın ve işlemin tamamlanmasını bekleyin. + +### 5. Derleyin ve Çalıştırın + +1. Projeyi **Android Studio** ile açın. +2. `TdApi.java` (TDLib sarmalayıcısı) dosyasının doğru şekilde indekslenebilmesi için IDE indeksleme limitlerini artırın. **Android Studio** veya **IntelliJ IDEA** içerisinde, **Help (Yardım) → Edit Custom Properties...** (Özel Özellikleri Düzenle) yolunu izleyin, aşağıdaki satırları yapıştırın ve istenirse IDE'yi yeniden başlatın: + +```properties +# Kb cinsinden boyut +idea.max.intellisense.filesize=20480 +# Kb cinsinden boyut +idea.max.content.load.filesize=20480 +``` + +3. Gradle senkronizasyonunu (Sync) yapın. +4. `app` çalıştırma yapılandırmasını (run configuration) seçin. +5. Bir cihaz bağlayın veya bir emülatör başlatın. +6. **Run** (Çalıştır) butonuna tıklayın. + +--- + +## TDLib Derlemesi + +Eğer TDLib'i kaynaktan derlemeniz gerekirse, öncelikle gerekli bağımlılıkları kurun. Debian/Ubuntu tabanlı dağıtımlar için: + +```bash +sudo apt-get update +sudo apt-get install build-essential git curl wget php perl gperf unzip zip default-jdk cmake +``` + +Ardından projenin kök dizininden derleme betiğini (script) çalıştırın: + +```bash +./build-tdlib.sh +``` + +--- + +## Katkıda Bulunma + +Katkılarınızı memnuniyetle karşılıyoruz! İster hataları gidermek, ister dokümantasyonu iyileştirmek veya yeni özellikler önermek olsun, her türlü katkıya açığız. + +1. **Sorunları (Issues) Kontrol Edin** — Açık sorunlara göz atın veya fikirlerinizi tartışmak için yeni bir sorun kaydı oluşturun. +2. **`develop` Dalında Çalışın** — Kendi dalınızı (branch) `develop` üzerinden oluşturun ve çalışmalarınızı bu dalı temel alarak sürdürün. +3. **Fork & Branch** — Depoyu (repo) çatallayın (fork) ve bir özellik dalı (feature branch) oluşturun. +4. **Kod Stili** — Mevcut Kotlin kod yazım stiline ve Temiz Mimari (Clean Architecture) yönergelerine uyun. +5. **PR Gönderin** — Değişikliklerinizin net bir açıklamasını içeren bir Çekme İsteğini (Pull Request) `develop` dalına açın. + +> [!IMPORTANT] +> - [Telegram API Hizmet Şartlarına](https://core.telegram.org/api/terms) uyun. +> - Kodunuzun tüm kontrollerden ve testlerden geçtiğinden emin olun. + +### Hata Bildirme ve Özellik Önerileri + +- **Hatalar (Bugs)** — Bir sorun kaydı (issue) açın ve başlıkta `[Bug]` etiketini kullanın (Örn: `[Bug] Uygulama başlangıçta çöküyor`). Ayrıca, bilinen tüm hatalara [**Hata Takipçisi**](https://github.com/orgs/monogram-android/projects/3/views/1) üzerinden göz atabilirsiniz. +- **Özellik İstekleri** — `[Feature]` etiketini içeren bir sorun kaydı açın (Örn: `[Feature] Planlanmış mesaj desteği`). Mevcut özellik isteklerini [**Özellik Panosu**](https://github.com/orgs/monogram-android/projects/5/views/1) üzerinden inceleyebilirsiniz. + +--- + +## Çeviriler + +MonoGram topluluk tarafından yapılan çevirileri memnuniyetle karşılar! Kendi dilinizle katkıda bulunmak için metin kaynak (strings resource) dosyasını düzenleyebilirsiniz. + +Kaynak metinler [`presentation/src/main/res/values/string.xml`](https://github.com/monogram-android/monogram/blob/develop/presentation/src/main/res/values/string.xml) adresinde yer almaktadır. Yeni bir dil eklemek için, ilgili dile ait bir `values-/string.xml` dosyası oluşturun (örneğin Almanca için `values-de/string.xml`) ve metinleri orada çevirin. Çevirinizi içeren bir Çekme İsteği (PR) açın, biz de onu projeye dahil edelim. + +--- + +## Teknoloji Yığını + +MonoGram, en güncel Android geliştirme araçlarından ve kütüphanelerinden yararlanır: + +| Kategori | Kütüphaneler | +|:---|:---| +| **Dil** | [Kotlin](https://kotlinlang.org/) | +| **Kullanıcı Arayüzü (UI)** | [Jetpack Compose](https://developer.android.com/jetpack/compose) (Material 3) | +| **Mimari** | [Decompose](https://github.com/arkivanov/Decompose) (Navigasyon), [MVIKotlin](https://github.com/arkivanov/MVIKotlin) | +| **Bağımlılık Enjeksiyonu (DI)** | [Koin](https://insert-koin.io/) | +| **Asenkron İşlemler** | Coroutines & Flow | +| **Telegram Çekirdeği** | [TDLib](https://core.telegram.org/tdlib) (Telegram Database Library) | +| **Görsel Yükleme** | [Coil 3](https://coil-kt.github.io/coil/) | +| **Medya** | Media3 (ExoPlayer) | +| **Haritalar** | [MapLibre](https://maplibre.org/) | +| **Yerel Veritabanı** | Room | + +--- + +## Proje Yapısı + +Proje, sorumlulukların ayrılmasını ve ölçeklenebilirliği sağlamak amacıyla çok modüllü (multi-module) bir yapı izlemektedir: + +| Modül | Açıklama | +|:---|:---| +| **:app** | Ana Android uygulama modülü. | +| **:domain** | İş mantığı (business logic), kullanım durumları (use cases) ve depo (repository) arayüzlerini içeren saf Kotlin modülü. | +| **:data** | Depo (repository) uygulamaları, veri kaynakları ve TDLib entegrasyonu. | +| **:presentation** | Kullanıcı arayüzü (UI) bileşenleri, ekranlar ve görünüm modelleri (MVI Store'ları). | +| **:core** | Modüller genelinde kullanılan ortak yardımcı sınıflar ve uzantılar (extensions). | +| **:baselineprofile** | Uygulama başlangıcı ve performans optimizasyonu için Baseline Profilleri. | + +--- + +## Lisans + +Bu proje [**GNU General Public License v3.0**](LICENSE) (GNU Genel Kamu Lisansı v3.0) kapsamında lisanslanmıştır. diff --git a/README_UR.md b/README_UR.md index 6515691e..1693a698 100644 --- a/README_UR.md +++ b/README_UR.md @@ -22,7 +22,7 @@ -**اسے دوسری زبانوں میں پڑھیں:** [English](README.md), [Русский](README_RU.md)، [한국어](README_KOR.md), +**اسے دوسری زبانوں میں پڑھیں:** [English](README.md), [Русский](README_RU.md)، [Türkçe](README_TR.md), [한국어](README_KOR.md), [Español](README_ES.md) --- diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ff16f497..214870b6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "org.monogram" minSdk = 25 targetSdk = 36 - versionCode = 7 - versionName = "0.0.7" + versionCode = 8 + versionName = "0.0.8" } splits { @@ -110,6 +110,16 @@ androidComponents { } } +configurations.configureEach { + val tink = "com.google.crypto.tink:tink-android:1.21.0" + resolutionStrategy { + force(tink) + dependencySubstitution { + substitute(module("com.google.crypto.tink:tink")).using(module(tink)) + } + } +} + dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.androidx.compose) @@ -126,6 +136,7 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) + implementation(libs.unifiedpush.connector) implementation(libs.maplibre.compose) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 713132ff..fd955b62 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -4,6 +4,8 @@ -keepnames class org.monogram.** -keepnames class org.drinkless.tdlib.** +-keepclassmembernames class org.monogram.** { *; } +-keepclassmembernames class org.drinkless.tdlib.** { *; } -assumenosideeffects class android.util.Log { public static *** v(...); diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d799dff8..f7cf541c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,6 +23,12 @@ android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> + + + + + + () - val isGmsAvailable = distrManager.isGmsAvailable() - val isFcmAvailable = distrManager.isFcmAvailable() - val prefs = get() - if (!(isGmsAvailable && isFcmAvailable) && prefs.pushProvider.value == PushProvider.FCM) { - prefs.setPushProvider(PushProvider.GMS_LESS) + val currentProvider = prefs.pushProvider.value + val bestAvailable = resolveBestAvailablePushProvider(distrManager) + + val shouldFallback = when (currentProvider) { + PushProvider.FCM -> bestAvailable != PushProvider.FCM + PushProvider.UNIFIED_PUSH -> !distrManager.isUnifiedPushDistributorAvailable() + PushProvider.GMS_LESS -> false + } + + if (shouldFallback && currentProvider != bestAvailable) { + prefs.setPushProvider(bestAvailable) + } + } + + private fun resolveBestAvailablePushProvider(distrManager: DistrManager): PushProvider { + val fcmAvailable = distrManager.isGmsAvailable() && distrManager.isFcmAvailable() + val unifiedPushAvailable = distrManager.isUnifiedPushDistributorAvailable() + + return when { + fcmAvailable -> PushProvider.FCM + unifiedPushAvailable -> PushProvider.UNIFIED_PUSH + else -> PushProvider.GMS_LESS } } diff --git a/app/src/main/java/org/monogram/app/MainActivity.kt b/app/src/main/java/org/monogram/app/MainActivity.kt index c051f5d3..163f28ef 100644 --- a/app/src/main/java/org/monogram/app/MainActivity.kt +++ b/app/src/main/java/org/monogram/app/MainActivity.kt @@ -107,7 +107,7 @@ class MainActivity : FragmentActivity() { } private fun startNotificationService() { - if (appPreferences.pushProvider.value == PushProvider.FCM) return + if (appPreferences.pushProvider.value != PushProvider.GMS_LESS) return val intent = Intent(this, TdNotificationService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(intent) diff --git a/app/src/main/java/org/monogram/app/di/DistrManagerImpl.kt b/app/src/main/java/org/monogram/app/di/DistrManagerImpl.kt index 3006cb30..0ea81ceb 100644 --- a/app/src/main/java/org/monogram/app/di/DistrManagerImpl.kt +++ b/app/src/main/java/org/monogram/app/di/DistrManagerImpl.kt @@ -6,6 +6,7 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.firebase.FirebaseApp import org.monogram.domain.managers.DistrManager +import org.unifiedpush.android.connector.UnifiedPush class DistrManagerImpl(private val context: Context) : DistrManager { override fun isGmsAvailable(): Boolean { @@ -16,6 +17,10 @@ class DistrManagerImpl(private val context: Context) : DistrManager { return FirebaseApp.getApps(context).isNotEmpty() } + override fun isUnifiedPushDistributorAvailable(): Boolean { + return UnifiedPush.getDistributors(context).isNotEmpty() + } + override fun isInstalledFromGooglePlay(): Boolean { val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000..430890e8 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,44 @@ + + + MonoGram Kilidini Aç + Biyometrik verilerinizi kullanarak giriş yapın + Şifre kullan + Şifreyi Girin + Mesajlarınız korunuyor + Geçersiz şifre + Biyometrik Kilidi Aç + + + Proxy Detayları + Bu proxy sunucusunu ekleyin ve bağlanın + Sunucu + Port + Tür + Bilinmiyor + İptal + Bağlan + + + Katıl + Kanal + Grup + + %d üye + %d üye + + + + Bir ayar seçin + Mesajlaşmaya başlamak için bir sohbet seçin + + + Hata Günlüğü + Panoya kopyalandı + Hata Günlüğünü Paylaş + Paylaş + Kopyala + Uygulamayı Yeniden Başlat + Hata Detayları + Bir şeyler ters gitti. Bu sorunu geliştiricilere bildirmek için lütfen aşağıdaki günlükleri paylaşın veya kopyalayın. + + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 392ba817..f58296dc 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -7,5 +7,6 @@ + diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 96b96568..c11bab91 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation(libs.androidx.media3.datasource) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) + implementation(libs.unifiedpush.connector) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml index 0be57ce1..8bf7ac1e 100644 --- a/data/src/main/AndroidManifest.xml +++ b/data/src/main/AndroidManifest.xml @@ -20,6 +20,14 @@ android:exported="false" /> + + + + + + diff --git a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt index 8bf1710a..ad8fa383 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -9,12 +9,19 @@ import org.monogram.core.DispatcherProvider import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.UserFullInfoDao import org.monogram.data.gateway.TelegramGateway -import org.monogram.data.mapper.* +import org.monogram.data.mapper.ChatMapper +import org.monogram.data.mapper.isForcedVerifiedChat +import org.monogram.data.mapper.isForcedVerifiedUser +import org.monogram.data.mapper.isSponsoredUser +import org.monogram.data.mapper.isValidFilePath import org.monogram.data.mapper.user.toEntity import org.monogram.data.mapper.user.toTdApi +import org.monogram.data.notifications.NotificationMuteResolver +import org.monogram.data.notifications.NotificationScopeState import org.monogram.domain.models.ChatModel import org.monogram.domain.models.UsernamesModel import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope import java.util.concurrent.ConcurrentHashMap class ChatModelFactory( @@ -27,6 +34,7 @@ class ChatModelFactory( private val typingManager: ChatTypingManager, private val appPreferences: AppPreferencesProvider, private val userFullInfoDao: UserFullInfoDao, + private val muteResolver: NotificationMuteResolver = NotificationMuteResolver(), private val triggerUpdate: (Long?) -> Unit, private val fetchUser: (Long) -> Unit ) { @@ -260,15 +268,24 @@ class ChatModelFactory( cache.usersCache[userId]?.firstName ?: run { fetchUser(userId); null } } - val isMuted = when { - chat.notificationSettings.muteFor > 0 -> true - chat.notificationSettings.useDefaultMuteFor -> when { - isChannel -> !appPreferences.channelsNotifications.value - isSupergroup || chat.type is TdApi.ChatTypeBasicGroup -> !appPreferences.groupsNotifications.value - else -> !appPreferences.privateChatsNotifications.value - } - else -> false - } + val scopeState = NotificationScopeState( + loadedScopes = setOf( + TdNotificationScope.PRIVATE_CHATS, + TdNotificationScope.GROUPS, + TdNotificationScope.CHANNELS + ), + enabledByScope = mapOf( + TdNotificationScope.PRIVATE_CHATS to appPreferences.privateChatsNotifications.value, + TdNotificationScope.GROUPS to appPreferences.groupsNotifications.value, + TdNotificationScope.CHANNELS to appPreferences.channelsNotifications.value + ) + ) + + val isMuted = muteResolver.resolve( + chat = chat, + cachedSettings = null, + scopeState = scopeState + ).isMuted return chatMapper.mapChatToModel( chat = chat, diff --git a/data/src/main/java/org/monogram/data/di/TdLibClient.kt b/data/src/main/java/org/monogram/data/di/TdLibClient.kt index 95395530..3005026b 100644 --- a/data/src/main/java/org/monogram/data/di/TdLibClient.kt +++ b/data/src/main/java/org/monogram/data/di/TdLibClient.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import org.drinkless.tdlib.Client import org.drinkless.tdlib.TdApi import org.monogram.data.gateway.TdLibException +import org.monogram.data.gateway.isExpectedProxyFailure import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.resume @@ -63,7 +64,12 @@ internal class TdLibClient { fun send(function: TdApi.Function, callback: (TdApi.Object) -> Unit = {}) { client.send(function) { result -> if (result is TdApi.Error) { - if (result.code != 404) { + if (result.isExpectedProxyFailure()) { + Log.w( + TAG, + "Expected proxy error in send $function: ${result.code} ${result.message}" + ) + } else if (result.code != 404) { Log.e(TAG, "Error in send $function: ${result.code} ${result.message}") } else { Log.w(TAG, "Not found in send $function: ${result.message}") @@ -113,6 +119,11 @@ internal class TdLibClient { if (isExpectedUserFullInfoMiss) { Log.w(TAG, "User not found in sendSuspend $function: ${result.code} ${result.message}") + } else if (result.isExpectedProxyFailure()) { + Log.w( + TAG, + "Expected proxy error in sendSuspend $function: ${result.code} ${result.message}" + ) } else if (result.code != 404) { Log.e(TAG, "Error in sendSuspend $function: ${result.code} ${result.message}") } else { diff --git a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt index 62f55b52..e8ef359a 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -8,26 +8,44 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.graphics.* +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Shader +import android.graphics.Typeface import android.os.Build import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.StyleSpan import android.util.Log import android.util.LruCache -import androidx.core.app.* +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.app.RemoteInput import androidx.core.graphics.createBitmap -import androidx.core.graphics.drawable.IconCompat -import androidx.core.graphics.drawable.toBitmap import com.google.firebase.messaging.FirebaseMessaging -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withTimeoutOrNull import org.drinkless.tdlib.TdApi import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.NotificationSettingDao import org.monogram.data.db.model.NotificationSettingEntity import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.notifications.NotificationMuteDecision +import org.monogram.data.notifications.NotificationMuteResolver +import org.monogram.data.notifications.NotificationScopeState +import org.monogram.data.push.UnifiedPushManager import org.monogram.data.service.NotificationDismissReceiver import org.monogram.data.service.NotificationReadReceiver import org.monogram.data.service.NotificationReplyReceiver @@ -46,13 +64,15 @@ class TdNotificationManager( private val notificationSettingsRepository: NotificationSettingsRepository, private val notificationSettingDao: NotificationSettingDao, private val fileQueue: FileDownloadQueue, - private val stringProvider: StringProvider + private val stringProvider: StringProvider, + private val unifiedPushManager: UnifiedPushManager, + private val muteResolver: NotificationMuteResolver ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val notificationManager = NotificationManagerCompat.from(context) private val userCache = ConcurrentHashMap() private val chatCache = ConcurrentHashMap() - private val messagesHistory = ConcurrentHashMap>>() + private val messagesHistory = ConcurrentHashMap>() private val lastMessageIds = ConcurrentHashMap() private val activeNotifications = ConcurrentHashMap>() private val bitmapCache = object : LruCache(5 * 1024 * 1024) { @@ -62,14 +82,11 @@ class TdNotificationManager( } private val activeDownloads = ConcurrentHashMap Unit>>() private val notificationSettingsCache = ConcurrentHashMap() - private val scopeNotificationsEnabled = ConcurrentHashMap() - private val loadedScopeSettings = ConcurrentHashMap.newKeySet() + private val scopeNotificationsEnabled = ConcurrentHashMap() + private val loadedScopeSettings = ConcurrentHashMap.newKeySet() - private enum class NotificationScopeKey { - PRIVATE, - GROUPS, - CHANNELS - } + @Volatile + private var myUserId: Long = 0L companion object { private const val TAG = "TdNotificationManager" @@ -85,6 +102,13 @@ class TdNotificationManager( const val KEY_TEXT_REPLY = "key_text_reply" } + private data class NotificationHistoryEntry( + val messageId: Long, + val senderName: String, + val text: String, + val timestamp: Long + ) + init { createNotificationChannels() loadSettingsFromDb() @@ -106,6 +130,7 @@ class TdNotificationManager( if (authenticated) { loadedScopeSettings.clear() scopeNotificationsEnabled.clear() + refreshMyUserId() fetchScopeNotificationSettings() fetchInitialExceptions() updatePushRegistration() @@ -116,7 +141,15 @@ class TdNotificationManager( scope.launch { gateway.updates.collect { update -> when (update) { - is TdApi.UpdateNewMessage -> handleNewMessage(update.message) + is TdApi.UpdateNewMessage -> { + val senderDebug = senderIdToDebug(update.message.senderId) + Log.d( + TAG, + "UpdateNewMessage chatId=${update.message.chatId} messageId=${update.message.id} " + + "outgoing=${update.message.isOutgoing} sender=$senderDebug" + ) + handleNewMessage(update.message) + } is TdApi.UpdateUser -> userCache[update.user.id] = update.user is TdApi.UpdateFile -> { val file = update.file @@ -155,7 +188,13 @@ class TdNotificationManager( } is TdApi.UpdateOption -> { if (update.name == "is_authenticated" && (update.value as? TdApi.OptionValueBoolean)?.value == true) { + refreshMyUserId() updatePushRegistration() + } else if (update.name == "my_id") { + val id = (update.value as? TdApi.OptionValueInteger)?.value ?: 0L + if (id > 0L) { + myUserId = id + } } } else -> {} @@ -168,6 +207,36 @@ class TdNotificationManager( updatePushRegistration() } } + + scope.launch { + appPreferences.privateChatsNotifications.collect { enabled -> + updateScopePreferenceState(TdNotificationScope.PRIVATE_CHATS, enabled) + } + } + + scope.launch { + appPreferences.groupsNotifications.collect { enabled -> + updateScopePreferenceState(TdNotificationScope.GROUPS, enabled) + } + } + + scope.launch { + appPreferences.channelsNotifications.collect { enabled -> + updateScopePreferenceState(TdNotificationScope.CHANNELS, enabled) + } + } + + scope.launch { + unifiedPushManager.endpoint.collect { + if (appPreferences.pushProvider.value == PushProvider.UNIFIED_PUSH && !it.isNullOrBlank()) { + Log.d( + TAG, + "UnifiedPush endpoint update observed, refreshing TDLib registration" + ) + updatePushRegistration() + } + } + } } private fun updateChatNotificationSettings(chatId: Long, settings: TdApi.ChatNotificationSettings) { @@ -182,12 +251,19 @@ class TdNotificationManager( } } + private fun updateScopePreferenceState(scope: TdNotificationScope, enabled: Boolean) { + if (!gateway.isAuthenticated.value) return + scopeNotificationsEnabled[scope] = enabled + loadedScopeSettings.add(scope) + } + private suspend fun updatePushRegistration() { if (!gateway.isAuthenticated.value) return when (appPreferences.pushProvider.value) { PushProvider.FCM -> { coRunCatching { + unifiedPushManager.unregister() val token = FirebaseMessaging.getInstance().token.await() gateway.execute( TdApi.RegisterDevice( @@ -195,17 +271,42 @@ class TdNotificationManager( longArrayOf() ) ) + Log.d(TAG, "RegisterDevice success for FCM") }.onFailure { Log.e(TAG, "FCM token registration failed", it) } } + PushProvider.UNIFIED_PUSH -> { + coRunCatching { + unifiedPushManager.ensureRegistered() + val endpoint = unifiedPushManager.endpoint.value + if (endpoint.isNullOrBlank()) { + Log.w(TAG, "UnifiedPush endpoint is not available yet") + return@coRunCatching + } + + gateway.execute( + TdApi.RegisterDevice( + TdApi.DeviceTokenSimplePush(endpoint), + longArrayOf() + ) + ) + Log.d( + TAG, + "RegisterDevice success for UnifiedPush endpoint=${endpoint.take(120)}" + ) + }.onFailure { Log.e(TAG, "UnifiedPush registration failed", it) } + } + PushProvider.GMS_LESS -> { coRunCatching { + unifiedPushManager.unregister() gateway.execute( TdApi.RegisterDevice( TdApi.DeviceTokenFirebaseCloudMessaging("", false), longArrayOf() ) ) + Log.d(TAG, "RegisterDevice success for GMS-less fallback") }.onFailure { Log.e(TAG, "GMS-less token registration failed", it) } } } @@ -243,53 +344,42 @@ class TdNotificationManager( if (!gateway.isAuthenticated.value) return val scopes = listOf( - NotificationScopeKey.PRIVATE to TdNotificationScope.PRIVATE_CHATS, - NotificationScopeKey.GROUPS to TdNotificationScope.GROUPS, - NotificationScopeKey.CHANNELS to TdNotificationScope.CHANNELS + TdNotificationScope.PRIVATE_CHATS, + TdNotificationScope.GROUPS, + TdNotificationScope.CHANNELS ) - scopes.forEach { (key, scope) -> + scopes.forEach { scope -> val enabled = coRunCatching { notificationSettingsRepository.getNotificationSettings(scope) } .getOrDefault(false) - scopeNotificationsEnabled[key] = enabled - loadedScopeSettings.add(key) + scopeNotificationsEnabled[scope] = enabled + loadedScopeSettings.add(scope) - when (key) { - NotificationScopeKey.PRIVATE -> appPreferences.setPrivateChatsNotifications(enabled) - NotificationScopeKey.GROUPS -> appPreferences.setGroupsNotifications(enabled) - NotificationScopeKey.CHANNELS -> appPreferences.setChannelsNotifications(enabled) + when (scope) { + TdNotificationScope.PRIVATE_CHATS -> appPreferences.setPrivateChatsNotifications( + enabled + ) + + TdNotificationScope.GROUPS -> appPreferences.setGroupsNotifications(enabled) + TdNotificationScope.CHANNELS -> appPreferences.setChannelsNotifications(enabled) } } } fun isChatMuted(chat: TdApi.Chat): Boolean { - val cached = notificationSettingsCache[chat.id] - val chatSettings = chat.notificationSettings - val muteFor = cached?.muteFor ?: chatSettings?.muteFor ?: return true - val useDefault = cached?.useDefault ?: chatSettings?.useDefaultMuteFor ?: return true - - return if (useDefault) { - val chatType = chat.type ?: return true - val scopeKey = when (chatType) { - is TdApi.ChatTypePrivate -> NotificationScopeKey.PRIVATE - is TdApi.ChatTypeBasicGroup -> NotificationScopeKey.GROUPS - is TdApi.ChatTypeSupergroup -> { - if (chatType.isChannel) NotificationScopeKey.CHANNELS else NotificationScopeKey.GROUPS - } - - else -> null - } - - if (scopeKey == null || !loadedScopeSettings.contains(scopeKey)) { - return true - } + return resolveMuteDecision(chat).isMuted + } - val globalEnabled = scopeNotificationsEnabled[scopeKey] ?: false - !globalEnabled - } else { - muteFor > 0 - } + private fun resolveMuteDecision(chat: TdApi.Chat): NotificationMuteDecision { + return muteResolver.resolve( + chat = chat, + cachedSettings = notificationSettingsCache[chat.id], + scopeState = NotificationScopeState( + loadedScopes = loadedScopeSettings.toSet(), + enabledByScope = scopeNotificationsEnabled.toMap() + ) + ) } fun clearHistory(chatId: Long) { @@ -298,7 +388,7 @@ class TdNotificationManager( activeNotifications.remove(chatId)?.forEach { notificationId -> notificationManager.cancel(notificationId) } - notificationManager.cancel(chatId.toInt()) + notificationManager.cancel(notificationIdForChat(chatId)) updateSummary() } @@ -306,13 +396,13 @@ class TdNotificationManager( activeNotifications[chatId]?.remove(notificationId) notificationManager.cancel(notificationId) - if (notificationId == chatId.toInt()) { + if (notificationId == notificationIdForChat(chatId)) { messagesHistory.remove(chatId) activeNotifications.remove(chatId) } else { val history = messagesHistory[chatId] if (history != null) { - history.removeAll { it.first == notificationId.toLong() } + history.removeAll { it.messageId == notificationId.toLong() } if (history.isEmpty()) { messagesHistory.remove(chatId) activeNotifications.remove(chatId) @@ -323,7 +413,19 @@ class TdNotificationManager( } private fun handleNewMessage(message: TdApi.Message) { - if (message.isOutgoing) return + Log.d( + TAG, + "handleNewMessage enter chatId=${message.chatId} messageId=${message.id} outgoing=${message.isOutgoing} " + + "content=${message.content?.javaClass?.simpleName ?: "null"}" + ) + + if (message.isOutgoing) { + Log.d( + TAG, + "Skip notification: outgoing message, chatId=${message.chatId}, messageId=${message.id}" + ) + return + } val messageContent = message.content if (messageContent == null) { @@ -337,64 +439,103 @@ class TdNotificationManager( return } + if (senderId is TdApi.MessageSenderUser && senderId.userId != 0L && senderId.userId == myUserId) { + Log.d( + TAG, + "Skip notification: sender is self, chatId=${message.chatId}, messageId=${message.id}" + ) + return + } + val lastId = lastMessageIds[message.chatId] if (lastId != null && message.id <= lastId) { + Log.d( + TAG, + "Skip notification: stale/duplicate message, chatId=${message.chatId}, messageId=${message.id}, lastId=$lastId" + ) return } lastMessageIds[message.chatId] = message.id - getChat(message.chatId) { chat -> - scope.launch { - val chatType = chat.type - if (chatType == null) { - Log.w(TAG, "Skipping notification for chat ${chat.id}: chat type is null") - return@launch - } + scope.launch { + val chat = getChatSuspend(message.chatId) + if (chat == null) { + Log.d( + TAG, + "Skip notification: chat unavailable, chatId=${message.chatId}, messageId=${message.id}" + ) + return@launch + } - val isMember = checkMembership(chat) - if (!isMember) { - Log.d(TAG, "Skipping notification for chat ${chat.id}: user is not a member") - return@launch - } + val chatType = chat.type + if (chatType == null) { + Log.w(TAG, "Skipping notification for chat ${chat.id}: chat type is null") + return@launch + } - if (isChatMuted(chat)) return@launch + val isMember = withTimeoutOrNull(1_500L) { checkMembership(chat) } ?: true + if (!isMember) { + Log.d(TAG, "Skipping notification for chat ${chat.id}: user is not a member") + return@launch + } - val contentText = - if (appPreferences.showSenderOnly.value) stringProvider.getString("notification_new_message") else getMessageText(messageContent) + val muteDecision = resolveMuteDecision(chat) + if (muteDecision.isMuted) { + Log.d( + TAG, + "Skip notification: muted reason=${muteDecision.reason} scope=${muteDecision.scope} " + + "muteFor=${muteDecision.muteFor} useDefault=${muteDecision.useDefault} " + + "chatId=${chat.id} messageId=${message.id}" + ) + return@launch + } - if (contentText.isBlank()) return@launch + val contentText = + if (appPreferences.showSenderOnly.value) stringProvider.getString("notification_new_message") else getMessageText( + messageContent + ) - val timestamp = message.date.toLong() * 1000 + if (contentText.isBlank()) { + Log.d( + TAG, + "Skip notification: empty content text, chatId=${chat.id}, messageId=${message.id}" + ) + return@launch + } - val shouldDownloadAvatar = - !appPreferences.isPowerSavingMode.value && !appPreferences.batteryOptimizationEnabled.value + val timestamp = message.date.toLong() * 1000 + val shouldPreloadAvatar = + !appPreferences.isPowerSavingMode.value && !appPreferences.batteryOptimizationEnabled.value - resolveSender(senderId, chat, !shouldDownloadAvatar) { senderName, senderBitmap -> - if (shouldDownloadAvatar) { - downloadAvatar(chat.photo, false) { chatIcon -> - appendMessageToNotification( - chatId = chat.id, - messageId = message.id, - chatType = chatType, - senderName = senderName, - senderBitmap = senderBitmap, - chatIcon = chatIcon ?: senderBitmap, - text = contentText, - timestamp = timestamp - ) - } - } else { - appendMessageToNotification( - chatId = chat.id, - messageId = message.id, - chatType = chatType, - senderName = senderName, - senderBitmap = senderBitmap, - chatIcon = senderBitmap, - text = contentText, - timestamp = timestamp - ) - } + resolveSender(senderId, chat, true) { senderName, senderBitmap -> + Log.d( + TAG, + "Resolved sender for notification chatId=${chat.id} messageId=${message.id} " + + "senderName=$senderName hasBitmap=${senderBitmap != null}" + ) + + Log.d( + TAG, + "Append notification chatId=${chat.id} messageId=${message.id} " + + "chatType=${chatType.javaClass.simpleName} text=${ + previewText( + contentText + ) + }" + ) + appendMessageToNotification( + chatId = chat.id, + messageId = message.id, + chatType = chatType, + senderName = senderName, + senderBitmap = senderBitmap, + chatIcon = senderBitmap, + text = contentText, + timestamp = timestamp + ) + + if (shouldPreloadAvatar && senderBitmap == null) { + preloadNotificationAssets(senderId, chat) } } } @@ -441,9 +582,16 @@ class TdNotificationManager( text: String, timestamp: Long ) { - if (text.isBlank()) return + if (text.isBlank()) { + Log.d( + TAG, + "Skip appendMessageToNotification: blank text, chatId=$chatId, messageId=$messageId" + ) + return + } if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "Skip appendMessageToNotification: missing POST_NOTIFICATIONS permission") return } @@ -454,97 +602,46 @@ class TdNotificationManager( else -> CHANNEL_OTHER } - val personBuilder = Person.Builder() - .setName(senderName) - .setKey(senderName) - - if (senderBitmap != null) { - personBuilder.setIcon(IconCompat.createWithBitmap(getCircularBitmap(senderBitmap))) - } - - val sender = personBuilder.build() - - val styleMessage = NotificationCompat.MessagingStyle.Message( - text, - timestamp, - sender - ) - val history = messagesHistory.getOrPut(chatId) { mutableListOf() } - history.add(messageId to styleMessage) + history.add( + NotificationHistoryEntry( + messageId = messageId, + senderName = senderName, + text = text, + timestamp = timestamp + ) + ) if (history.size > 10) { history.removeAt(0) } - val notificationId = chatId.toInt() - activeNotifications.getOrPut(chatId) { ConcurrentHashMap.newKeySet() }.add(notificationId) - - val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - putExtra("chat_id", chatId) - } - val pendingIntent = PendingIntent.getActivity( - context, - notificationId, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - val dismissIntent = Intent(context, NotificationDismissReceiver::class.java).apply { - putExtra("chat_id", chatId) - putExtra("notification_id", notificationId) - } - val dismissPendingIntent = PendingIntent.getBroadcast( - context, - notificationId, - dismissIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY) - .setLabel(stringProvider.getString("menu_reply")) - .build() - - val replyIntent = Intent(context, NotificationReplyReceiver::class.java).apply { - putExtra("chat_id", chatId) - putExtra("notification_id", notificationId) - } - val replyPendingIntent = PendingIntent.getBroadcast( - context, - notificationId, - replyIntent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - val readIntent = Intent(context, NotificationReadReceiver::class.java).apply { - putExtra("chat_id", chatId) - putExtra("notification_id", notificationId) - } - val readPendingIntent = PendingIntent.getBroadcast( - context, - notificationId, - readIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + val notificationId = notificationIdForChat(chatId) + Log.d( + TAG, + "Notification history updated chatId=$chatId size=${history.size} notificationId=$notificationId" ) + activeNotifications.getOrPut(chatId) { ConcurrentHashMap.newKeySet() }.add(notificationId) - val replyAction = NotificationCompat.Action.Builder( - android.R.drawable.ic_menu_send, - stringProvider.getString("menu_reply"), - replyPendingIntent - ).addRemoteInput(remoteInput).build() - - val readAction = NotificationCompat.Action.Builder( - android.R.drawable.ic_menu_view, - stringProvider.getString("action_mark_as_read"), - readPendingIntent - ).build() - + val pendingIntent = buildContentPendingIntent(chatId, notificationId) + val dismissPendingIntent = buildDismissPendingIntent(chatId, notificationId) + val replyAction = buildReplyAction(chatId, notificationId) + val readAction = buildReadAction(chatId, notificationId) val myself = Person.Builder().setName(stringProvider.getString("notification_person_me")).build() val messagingStyle = NotificationCompat.MessagingStyle(myself) - history.forEach { (_, msg) -> - messagingStyle.addMessage(msg) + history.forEach { entry -> + val person = Person.Builder() + .setName(entry.senderName) + .setKey(entry.senderName) + .build() + messagingStyle.addMessage( + NotificationCompat.MessagingStyle.Message( + entry.text, + entry.timestamp, + person + ) + ) } val isGroup = chatType !is TdApi.ChatTypePrivate @@ -560,51 +657,222 @@ class TdNotificationManager( 2 -> NotificationCompat.PRIORITY_HIGH else -> NotificationCompat.PRIORITY_DEFAULT } + val posted = runCatching { + val builder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(org.monogram.data.R.drawable.message_outline) + .setStyle(messagingStyle) + .setPriority(priority) + .setGroup(GROUP_CHATS) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setAutoCancel(true) + .setShortcutId(chatId.toString()) + .setLocusId(androidx.core.content.LocusIdCompat(chatId.toString())) + .setOnlyAlertOnce(true) + + pendingIntent?.let { builder.setContentIntent(it) } + dismissPendingIntent?.let { builder.setDeleteIntent(it) } + replyAction?.let { builder.addAction(it) } + readAction?.let { builder.addAction(it) } + + builder.setContentTitle(chatTitle) + builder.setContentText(text) + + if (appPreferences.inAppSounds.value) { + builder.setDefaults(NotificationCompat.DEFAULT_SOUND) + } else { + builder.setSilent(true) + } - val builder = NotificationCompat.Builder(context, channelId) - .setSmallIcon(org.monogram.data.R.drawable.message_outline) - .setStyle(messagingStyle) - .setPriority(priority) - .setContentIntent(pendingIntent) - .setDeleteIntent(dismissPendingIntent) - .addAction(replyAction) - .addAction(readAction) - .setGroup(GROUP_CHATS) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setAutoCancel(true) - .setShortcutId(chatId.toString()) - .setLocusId(androidx.core.content.LocusIdCompat(chatId.toString())) - .setOnlyAlertOnce(true) + if (appPreferences.inAppVibrate.value) { + when (appPreferences.notificationVibrationPattern.value) { + "short" -> builder.setVibrate(longArrayOf(0, 100, 50, 100)) + "long" -> builder.setVibrate(longArrayOf(0, 500, 200, 500)) + "disabled" -> builder.setVibrate(longArrayOf(0)) + else -> builder.setDefaults(NotificationCompat.DEFAULT_VIBRATE) + } + } - builder.setContentTitle(chatTitle) - builder.setContentText(text) + if (!appPreferences.inAppPreview.value) { + builder.setContentText(stringProvider.getString("notification_new_message")) + } - if (appPreferences.inAppSounds.value) { - builder.setDefaults(NotificationCompat.DEFAULT_SOUND) - } else { - builder.setSilent(true) + if (chatIcon != null) { + runCatching { builder.setLargeIcon(getCircularBitmap(chatIcon)) } + .onFailure { Log.w(TAG, "Failed to set large icon for notification", it) } + } + + notificationManager.notify(notificationId, builder.build()) + true + }.onFailure { + Log.e(TAG, "Failed to build rich notification, falling back", it) + }.getOrDefault(false) + + if (!posted) { + postFallbackNotification( + chatId = chatId, + chatType = chatType, + title = chatTitle, + text = text, + channelId = channelId, + notificationId = notificationId, + pendingIntent = pendingIntent, + dismissPendingIntent = dismissPendingIntent + ) } - if (appPreferences.inAppVibrate.value) { - when (appPreferences.notificationVibrationPattern.value) { - "short" -> builder.setVibrate(longArrayOf(0, 100, 50, 100)) - "long" -> builder.setVibrate(longArrayOf(0, 500, 200, 500)) - "disabled" -> builder.setVibrate(longArrayOf(0)) - else -> builder.setDefaults(NotificationCompat.DEFAULT_VIBRATE) - } + Log.d(TAG, "Notification posted chatId=$chatId notificationId=$notificationId") + updateSummary() + } + + private fun buildContentPendingIntent(chatId: Long, notificationId: Int): PendingIntent? { + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?.apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra("chat_id", chatId) + } ?: return null + + return runCatching { + PendingIntent.getActivity( + context, + notificationId, + launchIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + }.onFailure { + Log.w(TAG, "Failed to create content PendingIntent", it) + }.getOrNull() + } + + private fun buildDismissPendingIntent(chatId: Long, notificationId: Int): PendingIntent? { + val dismissIntent = Intent(context, NotificationDismissReceiver::class.java).apply { + putExtra("chat_id", chatId) + putExtra("notification_id", notificationId) } - if (!appPreferences.inAppPreview.value) { - builder.setContentText(stringProvider.getString("notification_new_message")) + return runCatching { + PendingIntent.getBroadcast( + context, + notificationId, + dismissIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + }.onFailure { + Log.w(TAG, "Failed to create dismiss PendingIntent", it) + }.getOrNull() + } + + private fun buildReplyAction(chatId: Long, notificationId: Int): NotificationCompat.Action? { + val replyIntent = Intent(context, NotificationReplyReceiver::class.java).apply { + putExtra("chat_id", chatId) + putExtra("notification_id", notificationId) } - if (chatIcon != null) { - Log.d("TdNotificationManager", "Setting chat icon to $chatTitle") - builder.setLargeIcon(getCircularBitmap(chatIcon)) + val replyPendingIntent = runCatching { + PendingIntent.getBroadcast( + context, + notificationId, + replyIntent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + }.getOrNull() ?: return null + + val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY) + .setLabel(stringProvider.getString("menu_reply")) + .build() + + return runCatching { + NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_send, + stringProvider.getString("menu_reply"), + replyPendingIntent + ).addRemoteInput(remoteInput).build() + }.onFailure { + Log.w(TAG, "Failed to build reply action", it) + }.getOrNull() + } + + private fun buildReadAction(chatId: Long, notificationId: Int): NotificationCompat.Action? { + val readIntent = Intent(context, NotificationReadReceiver::class.java).apply { + putExtra("chat_id", chatId) + putExtra("notification_id", notificationId) } - notificationManager.notify(notificationId, builder.build()) - updateSummary() + val readPendingIntent = runCatching { + PendingIntent.getBroadcast( + context, + notificationId, + readIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + }.getOrNull() ?: return null + + return runCatching { + NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_view, + stringProvider.getString("action_mark_as_read"), + readPendingIntent + ).build() + }.onFailure { + Log.w(TAG, "Failed to build read action", it) + }.getOrNull() + } + + private fun postFallbackNotification( + chatId: Long, + chatType: TdApi.ChatType, + title: String, + text: String, + channelId: String, + notificationId: Int, + pendingIntent: PendingIntent?, + dismissPendingIntent: PendingIntent? + ) { + runCatching { + val builder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(org.monogram.data.R.drawable.message_outline) + .setContentTitle(title) + .setContentText(if (appPreferences.inAppPreview.value) text else stringProvider.getString("notification_new_message")) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroup(GROUP_CHATS) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority( + when (appPreferences.notificationPriority.value) { + 0 -> NotificationCompat.PRIORITY_LOW + 2 -> NotificationCompat.PRIORITY_HIGH + else -> NotificationCompat.PRIORITY_DEFAULT + } + ) + + pendingIntent?.let { builder.setContentIntent(it) } + dismissPendingIntent?.let { builder.setDeleteIntent(it) } + + if (chatType !is TdApi.ChatTypePrivate) { + builder.setSubText(stringProvider.getString("notification_group_chats")) + } + + notificationManager.notify(notificationId, builder.build()) + Log.w(TAG, "Fallback notification posted chatId=$chatId notificationId=$notificationId") + }.onFailure { + Log.e(TAG, "Fallback notification failed chatId=$chatId notificationId=$notificationId", it) + } + } + + private fun notificationIdForChat(chatId: Long): Int { + val hash = (chatId xor (chatId ushr 32)).toInt() + return if (hash == SUMMARY_ID) SUMMARY_ID + 1 else hash + } + + private fun senderIdToDebug(senderId: TdApi.MessageSender?): String = when (senderId) { + null -> "null" + is TdApi.MessageSenderUser -> "user:${senderId.userId}" + is TdApi.MessageSenderChat -> "chat:${senderId.chatId}" + else -> senderId.javaClass.simpleName + } + + private fun previewText(text: String, max: Int = 80): String { + val normalized = text.replace('\n', ' ').trim() + return if (normalized.length <= max) normalized else normalized.take(max) + "..." } private fun getCircularBitmap(bitmap: Bitmap): Bitmap { @@ -645,7 +913,7 @@ class TdNotificationManager( } val allMessages = messagesHistory.flatMap { (chatId, messages) -> - messages.map { (_, message) -> + messages.map { message -> Triple(chatId, message, message.timestamp) } }.sortedByDescending { it.third } @@ -655,7 +923,7 @@ class TdNotificationManager( allMessages.take(5).forEach { (chatId, message, _) -> val chat = chatCache[chatId] - val senderName = message.person?.name ?: stringProvider.getString("unknown_user") + val senderName = message.senderName.ifBlank { stringProvider.getString("unknown_user") } val chatTitle = chat?.title ?: senderName val sb = SpannableStringBuilder() @@ -685,18 +953,6 @@ class TdNotificationManager( .setOnlyAlertOnce(true) .setContentTitle(summaryTitle) - val latestMessageTriple = allMessages.firstOrNull() - if (latestMessageTriple != null) { - val (_, message, _) = latestMessageTriple - val iconCompat = message.person?.icon - if (iconCompat != null) { - val drawable = iconCompat.loadDrawable(context) - if (drawable != null) { - builder.setLargeIcon(drawable.toBitmap()) - } - } - } - notificationManager.notify(SUMMARY_ID, builder.build()) } @@ -781,6 +1037,12 @@ class TdNotificationManager( } } + private suspend fun refreshMyUserId() { + myUserId = coRunCatching { + gateway.execute(TdApi.GetMe()).id + }.getOrDefault(myUserId) + } + private fun sanitizeSpoilers(formattedText: TdApi.FormattedText?): String { if (formattedText == null) return "" val text = formattedText.text.orEmpty() @@ -860,6 +1122,28 @@ class TdNotificationManager( when (senderId) { is TdApi.MessageSenderUser -> { + if (onlyIfLocal) { + val user = userCache[senderId.userId] + if (user == null) { + callback(fallbackName, null) + return + } + + val fullName = listOf(user.firstName, user.lastName) + .filterNotNull() + .filter { it.isNotBlank() } + .joinToString(" ") + val name = + if (chat.type is TdApi.ChatTypePrivate) fallbackName else fullName.ifBlank { fallbackName } + val file = + user.profilePhoto?.small + ?: if (chat.type is TdApi.ChatTypePrivate) chat.photo?.small else null + downloadFile(file, true) { bitmap -> + callback(name, bitmap) + } + return + } + getUser(senderId.userId) { user -> val fullName = listOf(user.firstName, user.lastName) .filterNotNull() @@ -876,6 +1160,20 @@ class TdNotificationManager( } is TdApi.MessageSenderChat -> { + if (onlyIfLocal) { + val senderChat = chatCache[senderId.chatId] + if (senderChat == null) { + callback(fallbackName, null) + return + } + + val name = senderChat.title?.takeIf { it.isNotBlank() } ?: fallbackName + downloadFile(senderChat.photo?.small, true) { bitmap -> + callback(name, bitmap) + } + return + } + getChat(senderId.chatId) { senderChat -> val name = senderChat.title?.takeIf { it.isNotBlank() } ?: fallbackName downloadFile(senderChat.photo?.small, onlyIfLocal) { bitmap -> @@ -892,12 +1190,9 @@ class TdNotificationManager( } } - private fun downloadAvatar( - fileInfo: TdApi.ChatPhotoInfo?, - onlyIfLocal: Boolean = false, - callback: (Bitmap?) -> Unit - ) { - downloadFile(fileInfo?.small, onlyIfLocal, callback) + private fun preloadNotificationAssets(senderId: TdApi.MessageSender?, chat: TdApi.Chat) { + resolveSender(senderId, chat, false) { _, _ -> } + downloadFile(chat.photo?.small, false) { _ -> } } private fun downloadFile(file: TdApi.File?, onlyIfLocal: Boolean = false, callback: (Bitmap?) -> Unit) { diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index d1c19d29..15c3ad88 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -81,6 +81,9 @@ import org.monogram.data.mapper.WebPageMapper import org.monogram.data.mapper.message.MessageContentMapper import org.monogram.data.mapper.message.MessagePersistenceMapper import org.monogram.data.mapper.message.MessageSenderResolver +import org.monogram.data.notifications.NotificationMuteResolver +import org.monogram.data.push.PushSyncTrigger +import org.monogram.data.push.UnifiedPushManager import org.monogram.data.repository.AttachMenuBotRepositoryImpl import org.monogram.data.repository.AuthRepositoryImpl import org.monogram.data.repository.BotRepositoryImpl @@ -100,6 +103,7 @@ import org.monogram.data.repository.PollRepositoryImpl import org.monogram.data.repository.PremiumRepositoryImpl import org.monogram.data.repository.PrivacyRepositoryImpl import org.monogram.data.repository.ProfilePhotoRepositoryImpl +import org.monogram.data.repository.PushDebugRepositoryImpl import org.monogram.data.repository.SessionRepositoryImpl import org.monogram.data.repository.SponsorRepositoryImpl import org.monogram.data.repository.StickerRepositoryImpl @@ -140,6 +144,7 @@ import org.monogram.domain.repository.PollRepository import org.monogram.domain.repository.PremiumRepository import org.monogram.domain.repository.PrivacyRepository import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.PushDebugRepository import org.monogram.domain.repository.SessionRepository import org.monogram.domain.repository.SponsorRepository import org.monogram.domain.repository.StickerRepository @@ -485,6 +490,10 @@ val dataModule = module { ) } + single { PushSyncTrigger(connectionManager = get(), gateway = get()) } + single { UnifiedPushManager(androidContext()) } + single { NotificationMuteResolver() } + single { ChatsListRepositoryImpl( remoteDataSource = get(), @@ -796,5 +805,27 @@ val dataModule = module { ) } - single(createdAtStart = true) { TdNotificationManager(androidContext(), get(), get(), get(), get(), get(), get()) } + single { + PushDebugRepositoryImpl( + context = androidContext(), + appPreferences = get(), + unifiedPushManager = get(), + pushSyncTrigger = get(), + scope = get() + ) + } + + single(createdAtStart = true) { + TdNotificationManager( + androidContext(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } } diff --git a/data/src/main/java/org/monogram/data/gateway/TdLibException.kt b/data/src/main/java/org/monogram/data/gateway/TdLibException.kt index 3d18f839..3dc2f100 100644 --- a/data/src/main/java/org/monogram/data/gateway/TdLibException.kt +++ b/data/src/main/java/org/monogram/data/gateway/TdLibException.kt @@ -4,6 +4,39 @@ import org.drinkless.tdlib.TdApi class TdLibException(val error: TdApi.Error) : Exception(error.message) +private val proxyResolveHostErrors = listOf( + "failed to resolve host", + "no address associated with hostname" +) + +private val proxyConnectivityErrors = listOf( + "response hash mismatch", + "connection refused", + "network is unreachable", + "timed out" +) + +fun TdApi.Error.isExpectedProxyFailure(): Boolean { + val text = message.orEmpty().lowercase() + return proxyResolveHostErrors.any(text::contains) || proxyConnectivityErrors.any(text::contains) +} + +fun Throwable.isExpectedProxyFailure(): Boolean { + val tdError = (this as? TdLibException)?.error + return tdError?.isExpectedProxyFailure() == true +} + +fun Throwable.toProxyFailureMessage(): String? { + val text = (this as? TdLibException)?.error?.message?.lowercase() ?: return null + return when { + proxyResolveHostErrors.any(text::contains) -> "Proxy host can't be resolved" + text.contains("response hash mismatch") -> "Invalid MTProto secret" + text.contains("connection refused") -> "Proxy connection refused" + text.contains("network is unreachable") || text.contains("timed out") -> "Proxy is unreachable" + else -> null + } +} + fun Throwable.toUserMessage(defaultMessage: String = "Unknown error"): String { val tdMessage = (this as? TdLibException)?.error?.message.orEmpty() return tdMessage.ifEmpty { message ?: defaultMessage } diff --git a/data/src/main/java/org/monogram/data/mapper/ProxyMapper.kt b/data/src/main/java/org/monogram/data/mapper/ProxyMapper.kt index 87018d60..b8efba41 100644 --- a/data/src/main/java/org/monogram/data/mapper/ProxyMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ProxyMapper.kt @@ -3,6 +3,7 @@ package org.monogram.data.mapper import org.drinkless.tdlib.TdApi import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.proxy.MtprotoSecretNormalizer fun TdApi.AddedProxy.toDomain(): ProxyModel = ProxyModel( id = id, @@ -23,5 +24,9 @@ fun TdApi.ProxyType.toDomain(): ProxyTypeModel = when (this) { fun ProxyTypeModel.toApi(): TdApi.ProxyType = when (this) { is ProxyTypeModel.Socks5 -> TdApi.ProxyTypeSocks5(username, password) is ProxyTypeModel.Http -> TdApi.ProxyTypeHttp(username, password, httpOnly) - is ProxyTypeModel.Mtproto -> TdApi.ProxyTypeMtproto(secret) + is ProxyTypeModel.Mtproto -> { + val normalized = MtprotoSecretNormalizer.normalize(secret) + ?: throw IllegalArgumentException("Invalid MTProto proxy secret") + TdApi.ProxyTypeMtproto(normalized) + } } diff --git a/data/src/main/java/org/monogram/data/notifications/NotificationMuteDecision.kt b/data/src/main/java/org/monogram/data/notifications/NotificationMuteDecision.kt new file mode 100644 index 00000000..e669fe35 --- /dev/null +++ b/data/src/main/java/org/monogram/data/notifications/NotificationMuteDecision.kt @@ -0,0 +1,19 @@ +package org.monogram.data.notifications + +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope + +data class NotificationMuteDecision( + val isMuted: Boolean, + val scope: TdNotificationScope?, + val reason: Reason, + val muteFor: Int, + val useDefault: Boolean +) { + enum class Reason { + CHAT_MUTED, + SCOPE_DISABLED, + NOT_MUTED, + SCOPE_NOT_LOADED, + UNKNOWN_SCOPE + } +} diff --git a/data/src/main/java/org/monogram/data/notifications/NotificationMuteResolver.kt b/data/src/main/java/org/monogram/data/notifications/NotificationMuteResolver.kt new file mode 100644 index 00000000..6e80a158 --- /dev/null +++ b/data/src/main/java/org/monogram/data/notifications/NotificationMuteResolver.kt @@ -0,0 +1,86 @@ +package org.monogram.data.notifications + +import org.drinkless.tdlib.TdApi +import org.monogram.data.db.model.NotificationSettingEntity +import org.monogram.data.notifications.NotificationMuteDecision.Reason +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope + +class NotificationMuteResolver { + + fun resolve( + chat: TdApi.Chat, + cachedSettings: NotificationSettingEntity?, + scopeState: NotificationScopeState + ): NotificationMuteDecision { + val muteFor = cachedSettings?.muteFor ?: chat.notificationSettings?.muteFor ?: 0 + val useDefault = + cachedSettings?.useDefault ?: chat.notificationSettings?.useDefaultMuteFor ?: true + + if (!useDefault) { + return if (muteFor > 0) { + NotificationMuteDecision( + isMuted = true, + scope = null, + reason = Reason.CHAT_MUTED, + muteFor = muteFor, + useDefault = false + ) + } else { + NotificationMuteDecision( + isMuted = false, + scope = null, + reason = Reason.NOT_MUTED, + muteFor = muteFor, + useDefault = false + ) + } + } + + val scope = resolveScope(chat.type) + if (scope == null) { + return NotificationMuteDecision( + isMuted = muteFor > 0, + scope = null, + reason = if (muteFor > 0) Reason.CHAT_MUTED else Reason.UNKNOWN_SCOPE, + muteFor = muteFor, + useDefault = true + ) + } + + if (!scopeState.loadedScopes.contains(scope)) { + return NotificationMuteDecision( + isMuted = false, + scope = scope, + reason = Reason.SCOPE_NOT_LOADED, + muteFor = muteFor, + useDefault = true + ) + } + + val enabled = scopeState.enabledByScope[scope] ?: true + return if (enabled) { + NotificationMuteDecision( + isMuted = false, + scope = scope, + reason = Reason.NOT_MUTED, + muteFor = muteFor, + useDefault = true + ) + } else { + NotificationMuteDecision( + isMuted = true, + scope = scope, + reason = Reason.SCOPE_DISABLED, + muteFor = muteFor, + useDefault = true + ) + } + } + + private fun resolveScope(chatType: TdApi.ChatType?): TdNotificationScope? = when (chatType) { + is TdApi.ChatTypePrivate -> TdNotificationScope.PRIVATE_CHATS + is TdApi.ChatTypeBasicGroup -> TdNotificationScope.GROUPS + is TdApi.ChatTypeSupergroup -> if (chatType.isChannel) TdNotificationScope.CHANNELS else TdNotificationScope.GROUPS + else -> null + } +} diff --git a/data/src/main/java/org/monogram/data/notifications/NotificationScopeState.kt b/data/src/main/java/org/monogram/data/notifications/NotificationScopeState.kt new file mode 100644 index 00000000..b80dc9ba --- /dev/null +++ b/data/src/main/java/org/monogram/data/notifications/NotificationScopeState.kt @@ -0,0 +1,8 @@ +package org.monogram.data.notifications + +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope + +data class NotificationScopeState( + val loadedScopes: Set, + val enabledByScope: Map +) diff --git a/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt b/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt new file mode 100644 index 00000000..33b1cb4f --- /dev/null +++ b/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt @@ -0,0 +1,62 @@ +package org.monogram.data.push + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.drinkless.tdlib.TdApi +import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.infra.ConnectionManager +import java.util.concurrent.atomic.AtomicLong + +class PushSyncTrigger( + private val connectionManager: ConnectionManager, + private val gateway: TelegramGateway +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val lastSyncAt = AtomicLong(0L) + + fun requestSync(reason: String) { + val now = System.currentTimeMillis() + val previous = lastSyncAt.get() + if (now - previous < MIN_SYNC_GAP_MS) { + Log.d(TAG, "Skip push sync by rate limit: reason=$reason") + return + } + if (!lastSyncAt.compareAndSet(previous, now)) { + Log.d(TAG, "Skip push sync by CAS race: reason=$reason") + return + } + + scope.launch { + if (!gateway.isAuthenticated.value) { + Log.d(TAG, "Skip push sync: not authenticated, reason=$reason") + return@launch + } + + Log.d(TAG, "Triggering TDLib sync from push: reason=$reason") + connectionManager.retryConnection() + + delay(PUSH_SYNC_DELAY_MS) + + val me = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { + runCatching { gateway.execute(TdApi.GetMe()) }.getOrNull() + } + if (me != null) { + Log.d(TAG, "Push sync probe success: me=${me.id}") + } else { + Log.w(TAG, "Push sync probe failed (GetMe timeout/error)") + } + } + } + + private companion object { + const val TAG = "PushSyncTrigger" + const val MIN_SYNC_GAP_MS = 1500L + const val PUSH_SYNC_DELAY_MS = 350L + const val REQUEST_TIMEOUT_MS = 5000L + } +} diff --git a/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt b/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt new file mode 100644 index 00000000..1cb5b33b --- /dev/null +++ b/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt @@ -0,0 +1,169 @@ +package org.monogram.data.push + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.ResolvedDistributor + +class UnifiedPushManager( + private val context: Context +) { + enum class Status { + IDLE, + REGISTERING, + REGISTERED, + FAILED, + UNREGISTERED + } + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val _endpoint = MutableStateFlow(loadEndpoint()) + val endpoint: StateFlow = _endpoint.asStateFlow() + + private val _status = MutableStateFlow(loadStatus()) + val status: StateFlow = _status.asStateFlow() + + fun isDistributorAvailable(): Boolean = UnifiedPush.getDistributors(context).isNotEmpty() + + fun getDistributors(): List = UnifiedPush.getDistributors(context) + + fun getSavedDistributor(): String? = UnifiedPush.getSavedDistributor(context) + + fun getAckDistributor(): String? = UnifiedPush.getAckDistributor(context) + + fun currentDistributor(): String? { + val distributors = getDistributors() + val ack = getAckDistributor() + if (!ack.isNullOrBlank() && distributors.contains(ack)) { + Log.d(TAG, "Select distributor from ack: $ack") + return ack + } + + val saved = getSavedDistributor() + if (!saved.isNullOrBlank() && distributors.contains(saved)) { + Log.d(TAG, "Select distributor from saved: $saved") + return saved + } + + val resolved = UnifiedPush.resolveDefaultDistributor(context) + if (resolved is ResolvedDistributor.Found && distributors.contains(resolved.packageName)) { + Log.d(TAG, "Select distributor from default resolver: ${resolved.packageName}") + return resolved.packageName + } + + val first = distributors.firstOrNull() + if (first != null) { + Log.d(TAG, "Select distributor fallback first installed: $first") + } + return first + } + + fun ensureRegistered(force: Boolean = false): Boolean { + val distributor = currentDistributor() ?: return false + val endpointKnown = !_endpoint.value.isNullOrBlank() + if (!force && endpointKnown && _status.value == Status.REGISTERED && !shouldRefreshRegistration()) { + Log.d(TAG, "Skip re-register: endpoint already known and fresh") + return true + } + + _status.value = Status.REGISTERING + markRegistrationAttempt() + Log.d( + TAG, + "Request UnifiedPush register: distributor=$distributor force=$force endpointKnown=$endpointKnown" + ) + + return runCatching { + UnifiedPush.saveDistributor(context, distributor) + UnifiedPush.register(context, INSTANCE_ID) + }.onFailure { + _status.value = Status.FAILED + Log.e(TAG, "Failed to request UnifiedPush registration", it) + }.isSuccess + } + + fun unregister() { + runCatching { + UnifiedPush.unregister(context, INSTANCE_ID) + }.onFailure { + Log.e(TAG, "Failed to request UnifiedPush unregister", it) + } + clearEndpoint() + _status.value = Status.UNREGISTERED + } + + fun onNewEndpoint(endpoint: PushEndpoint) { + onNewEndpoint(endpoint.url) + } + + fun onNewEndpoint(endpoint: String?) { + val value = endpoint?.trim().orEmpty() + if (value.isEmpty()) { + _status.value = Status.FAILED + return + } + + prefs.edit() + .putString(KEY_ENDPOINT, value) + .putLong(KEY_LAST_REGISTERED_AT, System.currentTimeMillis()) + .apply() + _endpoint.value = value + _status.value = Status.REGISTERED + Log.d(TAG, "UnifiedPush endpoint saved: ${value.take(140)}") + } + + fun onRegistrationFailed(reason: FailedReason?) { + _status.value = Status.FAILED + if (reason != null) { + Log.w(TAG, "UnifiedPush registration failed: $reason") + } + } + + fun onTempUnavailable() { + _status.value = Status.FAILED + Log.w(TAG, "UnifiedPush distributor temporarily unavailable") + } + + fun onUnregistered() { + clearEndpoint() + _status.value = Status.UNREGISTERED + } + + fun shouldRefreshRegistration(): Boolean { + val last = prefs.getLong(KEY_LAST_REGISTERED_AT, 0L) + if (last <= 0L) return true + return System.currentTimeMillis() - last >= REFRESH_INTERVAL_MS + } + + private fun clearEndpoint() { + prefs.edit().remove(KEY_ENDPOINT).apply() + _endpoint.value = null + } + + private fun markRegistrationAttempt() { + prefs.edit().putLong(KEY_LAST_REGISTER_ATTEMPT_AT, System.currentTimeMillis()).apply() + } + + private fun loadEndpoint(): String? = + prefs.getString(KEY_ENDPOINT, null)?.takeIf { it.isNotBlank() } + + private fun loadStatus(): Status { + return if (_endpoint.value.isNullOrBlank()) Status.IDLE else Status.REGISTERED + } + + private companion object { + const val TAG = "UnifiedPushManager" + const val PREFS_NAME = "unified_push_state" + const val KEY_ENDPOINT = "endpoint" + const val KEY_LAST_REGISTERED_AT = "last_registered_at" + const val KEY_LAST_REGISTER_ATTEMPT_AT = "last_register_attempt_at" + const val REFRESH_INTERVAL_MS = 24L * 60L * 60L * 1000L + const val INSTANCE_ID = "monogram_default" + } +} diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index 64848ef5..9f4e5b98 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -197,6 +197,8 @@ class ChatsListRepositoryImpl( private val modelCache = SynchronizedLruMap(MODEL_CACHE_SIZE) private val invalidatedModels = ConcurrentHashMap.newKeySet() + @Volatile + private var invalidateAllModels = true private var lastList: List? = null private var lastListFolderId: Int = -1 @@ -293,7 +295,11 @@ class ChatsListRepositoryImpl( } private fun rebuildChatModels(limit: Int): List { - return listManager.rebuildChatList(limit, emptyList()) { chat, order, isPinned -> + if (!invalidateAllModels) { + rebuildVisibleModels(limit)?.let { return it } + } + + val rebuilt = listManager.rebuildChatList(limit, emptyList()) { chat, order, isPinned -> val cached = modelCache[chat.id] if (cached != null && cached.order == order && @@ -308,6 +314,37 @@ class ChatsListRepositoryImpl( } } } + invalidatedModels.clear() + invalidateAllModels = false + return rebuilt + } + + private fun rebuildVisibleModels(limit: Int): List? { + val previous = lastList ?: return null + if (lastListFolderId != activeFolderId) return null + if (invalidatedModels.isEmpty()) return previous + + val visibleIndexes = previous.mapIndexed { index, chat -> chat.id to index }.toMap() + val updated = previous.toMutableList() + + for (chatId in invalidatedModels.toList()) { + val index = visibleIndexes[chatId] ?: return null + val chat = cache.allChats[chatId] ?: return null + val position = cache.activeListPositions[chatId] ?: return null + val oldModel = previous[index] + if (oldModel.order != position.order || oldModel.isPinned != position.isPinned) { + return null + } + updated[index] = modelFactory.mapChatToModel(chat, position.order, position.isPinned).also { mapped -> + modelCache[chatId] = mapped + } + invalidatedModels.remove(chatId) + } + + if (updated.size > limit) { + return null + } + return updated } private fun shouldEmitList(folderId: Int, newList: List): Boolean { @@ -324,6 +361,7 @@ class ChatsListRepositoryImpl( private fun clearTransientState() { modelCache.clear() invalidatedModels.clear() + invalidateAllModels = true lastList = null lastListFolderId = -1 _chatListFlow.value = emptyList() @@ -332,6 +370,7 @@ class ChatsListRepositoryImpl( private fun triggerUpdate(chatId: Long? = null) { if (chatId == null) { + invalidateAllModels = true invalidatedModels.addAll(cache.activeListPositions.keys) } else { invalidatedModels.add(chatId) diff --git a/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt index 3e622906..10fc6da7 100644 --- a/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt @@ -1,12 +1,14 @@ package org.monogram.data.repository -import kotlinx.coroutines.* +import kotlinx.coroutines.withTimeoutOrNull import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.remote.ProxyRemoteDataSource +import org.monogram.data.gateway.toProxyFailureMessage import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.ExternalProxyRepository +import org.monogram.domain.repository.ProxyTestResult class ExternalProxyRepositoryImpl( private val remote: ProxyRemoteDataSource, @@ -50,17 +52,51 @@ class ExternalProxyRepositoryImpl( }.getOrDefault(false) override suspend fun pingProxy(proxyId: Int): Long? = withTimeoutOrNull(10_000L) { + when (val result = pingProxyDetailed(proxyId)) { + is ProxyTestResult.Success -> result.ping + is ProxyTestResult.Failure -> null + } + } + + override suspend fun pingProxyDetailed(proxyId: Int): ProxyTestResult = + withTimeoutOrNull(10_000L) { coRunCatching { - val proxy = remote.getProxies().find { it.id == proxyId } ?: return@withTimeoutOrNull null + val proxy = remote.getProxies().find { it.id == proxyId } ?: return@coRunCatching null remote.pingProxy(proxy.server, proxy.port, proxy.type) - }.getOrNull() - } + }.fold( + onSuccess = { ping -> + if (ping == null) ProxyTestResult.Failure("Proxy is unreachable") + else ProxyTestResult.Success(ping) + }, + onFailure = { + ProxyTestResult.Failure(it.toProxyFailureMessage() ?: "Proxy is unreachable") + } + ) + } ?: ProxyTestResult.Failure("Proxy is unreachable") override suspend fun testProxy(server: String, port: Int, type: ProxyTypeModel): Long? = - withTimeoutOrNull(10_000L) { - coRunCatching { remote.testProxy(server, port, type) }.getOrNull() + when (val result = testProxyDetailed(server, port, type)) { + is ProxyTestResult.Success -> result.ping + is ProxyTestResult.Failure -> null } + override suspend fun testProxyDetailed( + server: String, + port: Int, + type: ProxyTypeModel + ): ProxyTestResult = + withTimeoutOrNull(10_000L) { + coRunCatching { remote.testProxy(server, port, type) } + .fold( + onSuccess = { ProxyTestResult.Success(it) }, + onFailure = { + ProxyTestResult.Failure( + it.toProxyFailureMessage() ?: "Proxy is unreachable" + ) + } + ) + } ?: ProxyTestResult.Failure("Proxy is unreachable") + override fun setPreferIpv6(enabled: Boolean) { appPreferences.setPreferIpv6(enabled) } diff --git a/data/src/main/java/org/monogram/data/repository/LinkParser.kt b/data/src/main/java/org/monogram/data/repository/LinkParser.kt index b7019054..6c77602a 100644 --- a/data/src/main/java/org/monogram/data/repository/LinkParser.kt +++ b/data/src/main/java/org/monogram/data/repository/LinkParser.kt @@ -3,6 +3,7 @@ package org.monogram.data.repository import androidx.core.net.toUri import org.monogram.data.core.coRunCatching import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.proxy.MtprotoSecretNormalizer class LinkParser { @@ -89,7 +90,10 @@ class LinkParser { val pass = uri.getQueryParameter("pass") val type = when { - secret != null -> ProxyTypeModel.Mtproto(secret) + secret != null -> { + val normalized = MtprotoSecretNormalizer.normalize(secret) ?: return null + ProxyTypeModel.Mtproto(normalized) + } isHttp -> ProxyTypeModel.Http(user ?: "", pass ?: "", false) else -> ProxyTypeModel.Socks5(user ?: "", pass ?: "") } diff --git a/data/src/main/java/org/monogram/data/repository/PushDebugRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/PushDebugRepositoryImpl.kt new file mode 100644 index 00000000..fc7b06ec --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/PushDebugRepositoryImpl.kt @@ -0,0 +1,139 @@ +package org.monogram.data.repository + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.monogram.data.push.PushSyncTrigger +import org.monogram.data.push.UnifiedPushManager +import org.monogram.data.service.TdNotificationService +import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.PushDebugRepository +import org.monogram.domain.repository.PushDiagnostics +import org.monogram.domain.repository.UnifiedPushDebugStatus + +class PushDebugRepositoryImpl( + private val context: Context, + private val appPreferences: AppPreferencesProvider, + private val unifiedPushManager: UnifiedPushManager, + private val pushSyncTrigger: PushSyncTrigger, + private val scope: CoroutineScope +) : PushDebugRepository { + + private val _diagnostics = MutableStateFlow(PushDiagnostics()) + override val diagnostics: StateFlow = _diagnostics + + init { + val prefsFlow = combine( + appPreferences.pushProvider, + appPreferences.backgroundServiceEnabled, + appPreferences.hideForegroundNotification, + appPreferences.isPowerSavingMode, + appPreferences.isWakeLockEnabled + ) { provider, bgEnabled, hideForeground, powerSaving, wakeLock -> + PrefSnapshot(provider, bgEnabled, hideForeground, powerSaving, wakeLock) + } + + scope.launch { + combine( + prefsFlow, + appPreferences.batteryOptimizationEnabled, + TdNotificationService.isRunningFlow, + unifiedPushManager.status, + unifiedPushManager.endpoint + ) { prefs, batteryOpt, serviceRunning, unifiedStatus, endpoint -> + PushDiagnostics( + pushProvider = prefs.pushProvider, + backgroundServiceEnabled = prefs.backgroundServiceEnabled, + hideForegroundNotification = prefs.hideForegroundNotification, + isPowerSavingMode = prefs.isPowerSavingMode, + isWakeLockEnabled = prefs.isWakeLockEnabled, + batteryOptimizationEnabled = batteryOpt, + isTdNotificationServiceRunning = serviceRunning, + unifiedPushStatus = when (unifiedStatus) { + UnifiedPushManager.Status.IDLE -> UnifiedPushDebugStatus.IDLE + UnifiedPushManager.Status.REGISTERING -> UnifiedPushDebugStatus.REGISTERING + UnifiedPushManager.Status.REGISTERED -> UnifiedPushDebugStatus.REGISTERED + UnifiedPushManager.Status.FAILED -> UnifiedPushDebugStatus.FAILED + UnifiedPushManager.Status.UNREGISTERED -> UnifiedPushDebugStatus.UNREGISTERED + }, + unifiedPushEndpoint = endpoint, + unifiedPushSavedDistributor = unifiedPushManager.getSavedDistributor(), + unifiedPushAckDistributor = unifiedPushManager.getAckDistributor(), + unifiedPushDistributorsCount = unifiedPushManager.getDistributors().size + ) + }.collect { + _diagnostics.value = it + } + } + } + + override fun triggerTestPush() { + ensureDebugChannel() + + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + val pendingIntent = PendingIntent.getActivity( + context, + 9110, + launchIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val builder = NotificationCompat.Builder(context, DEBUG_CHANNEL_ID) + .setSmallIcon(org.monogram.data.R.drawable.message_outline) + .setContentTitle("MonoGram Debug Push") + .setContentText("Synthetic push signal delivered") + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + ) { + NotificationManagerCompat.from(context).notify(DEBUG_NOTIFICATION_ID, builder.build()) + } + + pushSyncTrigger.requestSync("debug_test_push") + } + + private fun ensureDebugChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val manager = context.getSystemService(NotificationManager::class.java) ?: return + val channel = NotificationChannel( + DEBUG_CHANNEL_ID, + "Debug Push", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Debug notifications for push diagnostics" + setShowBadge(false) + } + manager.createNotificationChannel(channel) + } + + private data class PrefSnapshot( + val pushProvider: org.monogram.domain.repository.PushProvider, + val backgroundServiceEnabled: Boolean, + val hideForegroundNotification: Boolean, + val isPowerSavingMode: Boolean, + val isWakeLockEnabled: Boolean + ) + + private companion object { + const val DEBUG_CHANNEL_ID = "debug_push_channel" + const val DEBUG_NOTIFICATION_ID = 9110 + } +} diff --git a/data/src/main/java/org/monogram/data/service/FcmPushService.kt b/data/src/main/java/org/monogram/data/service/FcmPushService.kt index e310821e..272d3f76 100644 --- a/data/src/main/java/org/monogram/data/service/FcmPushService.kt +++ b/data/src/main/java/org/monogram/data/service/FcmPushService.kt @@ -4,7 +4,12 @@ import android.os.PowerManager import android.util.Log import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import org.drinkless.tdlib.TdApi import org.json.JSONObject import org.koin.android.ext.android.inject @@ -34,37 +39,43 @@ class FcmPushService : FirebaseMessagingService() { if (appPreferences.pushProvider.value != PushProvider.FCM) return val data = message.data - if (data.isNotEmpty()) { - val powerManager = getSystemService(POWER_SERVICE) as PowerManager - val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "monogram:FcmPushService") + if (data.isEmpty()) return - try { - val json = JSONObject() - for ((k, v) in data) { - json.put(k, v) + val powerManager = getSystemService(POWER_SERVICE) as? PowerManager ?: return + val wakeLock = + powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "monogram:FcmPushService") + .apply { + setReferenceCounted(false) } - val jsonPayload = json.toString() - wakeLock.acquire(10_000L) - scope.launch { - try { + try { + val json = JSONObject() + for ((k, v) in data) { + json.put(k, v) + } + val jsonPayload = json.toString() + if (jsonPayload.isBlank()) return + + wakeLock.acquire(10_000L) + scope.launch { + try { + withTimeout(8_000L) { gateway.execute(TdApi.ProcessPushNotification(jsonPayload)) - Log.d("FcmPushService", "ProcessPushNotification success") - delay(5000) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("FcmPushService", "Error processing push", e) - } finally { - if (wakeLock.isHeld) { - wakeLock.release() - } + } + Log.d("FcmPushService", "ProcessPushNotification success") + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("FcmPushService", "Error processing push", e) + } finally { + if (wakeLock.isHeld) { + wakeLock.release() } } - } catch (e: Exception) { - Log.e("FcmPushService", "Error processing push", e) - if (wakeLock.isHeld) { - wakeLock.release() - } + } + } catch (e: Exception) { + Log.e("FcmPushService", "Error preparing push payload", e) + if (wakeLock.isHeld) { + wakeLock.release() } } } diff --git a/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt b/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt index adc8e7f1..ec33c595 100644 --- a/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt +++ b/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt @@ -10,13 +10,11 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.monogram.data.di.TdNotificationManager import org.monogram.data.gateway.TelegramGateway -import org.monogram.domain.repository.StringProvider class NotificationReplyReceiver : BroadcastReceiver(), KoinComponent { private val gateway: TelegramGateway by inject() private val notificationManager: TdNotificationManager by inject() - private val stringProvider: StringProvider by inject() override fun onReceive(context: Context, intent: Intent) { val chatId = intent.getLongExtra("chat_id", 0L) @@ -38,8 +36,6 @@ class NotificationReplyReceiver : BroadcastReceiver(), KoinComponent { runCatching { gateway.execute(actionTyping) } } - val chat = gateway.execute(TdApi.GetChat(chatId)) - val inputMessageContent = TdApi.InputMessageText().apply { this.text = TdApi.FormattedText(replyText, emptyArray()) this.clearDraft = true @@ -60,17 +56,6 @@ class NotificationReplyReceiver : BroadcastReceiver(), KoinComponent { if (notificationId != 0) { notificationManager.removeNotification(chatId, notificationId) } - - notificationManager.appendMessageToNotification( - chatId = chatId, - messageId = System.currentTimeMillis(), - chatType = chat.type, - senderName = stringProvider.getString("notification_person_me"), - senderBitmap = null, - chatIcon = null, - text = replyText, - timestamp = System.currentTimeMillis() - ) } catch (e: Exception) { e.printStackTrace() } diff --git a/data/src/main/java/org/monogram/data/service/TdNotificationService.kt b/data/src/main/java/org/monogram/data/service/TdNotificationService.kt index ada8254f..cd02c8a2 100644 --- a/data/src/main/java/org/monogram/data/service/TdNotificationService.kt +++ b/data/src/main/java/org/monogram/data/service/TdNotificationService.kt @@ -11,8 +11,18 @@ import android.os.IBinder import android.os.PowerManager import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.PushProvider @@ -32,23 +42,27 @@ class TdNotificationService : Service() { const val FOREGROUND_ID = 999 const val ACTION_STOP = "org.monogram.data.service.ACTION_STOP" private const val CHECK_INTERVAL = 60_000L // 1 minute + + private val _isRunningFlow = MutableStateFlow(false) + val isRunningFlow: StateFlow = _isRunningFlow.asStateFlow() } override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action == ACTION_STOP) { - stopForegroundService() + stopForegroundService(userInitiated = true) return START_NOT_STICKY } if (!isServiceRunning) { isServiceRunning = true + _isRunningFlow.value = true // Call startForeground as soon as possible to satisfy // startForegroundService() timing requirements on Android 8+. startForegroundNotification() - if (appPreferences.pushProvider.value == PushProvider.FCM) { + if (appPreferences.pushProvider.value != PushProvider.GMS_LESS) { stopForegroundService() return START_NOT_STICKY } @@ -56,7 +70,7 @@ class TdNotificationService : Service() { acquireWakeLock() startListeningUpdates() startPeriodicCheck() - } else if (appPreferences.pushProvider.value == PushProvider.FCM) { + } else if (appPreferences.pushProvider.value != PushProvider.GMS_LESS) { stopForegroundService() return START_NOT_STICKY } @@ -64,7 +78,7 @@ class TdNotificationService : Service() { } private fun acquireWakeLock() { - if (appPreferences.pushProvider.value == PushProvider.FCM) return + if (appPreferences.pushProvider.value != PushProvider.GMS_LESS) return if (appPreferences.isPowerSavingMode.value) return if (!appPreferences.isWakeLockEnabled.value) return @@ -159,9 +173,15 @@ class TdNotificationService : Service() { } } - private fun stopForegroundService() { + private fun stopForegroundService(userInitiated: Boolean = false) { isServiceRunning = false - appPreferences.setBackgroundServiceEnabled(false) + _isRunningFlow.value = false + checkJob?.cancel() + checkJob = null + releaseWakeLock() + if (userInitiated) { + appPreferences.setBackgroundServiceEnabled(false) + } try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE) @@ -185,13 +205,12 @@ class TdNotificationService : Service() { ) { powerSaving, wakeLockEnabled, batteryOptimization, pushProvider -> Quadruple(powerSaving, wakeLockEnabled, batteryOptimization, pushProvider) }.collect { (isPowerSaving, isWakeLockEnabled, isBatteryOptimization, pushProvider) -> - if (pushProvider == PushProvider.FCM) { + if (pushProvider != PushProvider.GMS_LESS) { stopForegroundService() return@collect } if (isPowerSaving || !isWakeLockEnabled || isBatteryOptimization) { - delay(5000) releaseWakeLock() } else { if (isServiceRunning) { @@ -206,7 +225,7 @@ class TdNotificationService : Service() { checkJob?.cancel() checkJob = serviceScope.launch { while (isActive) { - if (appPreferences.pushProvider.value == PushProvider.FCM || (!appPreferences.backgroundServiceEnabled.value && appPreferences.pushProvider.value == PushProvider.GMS_LESS)) { + if (appPreferences.pushProvider.value != PushProvider.GMS_LESS || !appPreferences.backgroundServiceEnabled.value) { stopForegroundService() break } @@ -246,6 +265,7 @@ class TdNotificationService : Service() { override fun onDestroy() { super.onDestroy() isServiceRunning = false + _isRunningFlow.value = false serviceScope.cancel() releaseWakeLock() } diff --git a/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt b/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt new file mode 100644 index 00000000..15244aa4 --- /dev/null +++ b/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt @@ -0,0 +1,80 @@ +package org.monogram.data.service + +import android.util.Log +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.monogram.data.push.PushSyncTrigger +import org.monogram.data.push.UnifiedPushManager +import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.PushProvider +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage + +class UnifiedPushService : PushService(), KoinComponent { + private val unifiedPushManager: UnifiedPushManager by inject() + private val pushSyncTrigger: PushSyncTrigger by inject() + private val appPreferences: AppPreferencesProvider by inject() + + override fun onMessage(message: PushMessage, instance: String) { + val payload = + runCatching { message.content.toString(Charsets.UTF_8) }.getOrDefault("") + Log.d( + TAG, + "onMessage instance=$instance decrypted=${message.decrypted} bytes=${message.content.size} payload=${ + payload.take( + 96 + ) + }" + ) + + if (!isUnifiedPushSelected()) { + Log.d(TAG, "Ignore UnifiedPush message: provider is not UnifiedPush") + return + } + + val reason = + if (payload.startsWith("version=")) "unified_push_telegram_simple" else "unified_push_message" + pushSyncTrigger.requestSync(reason) + } + + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + Log.d( + TAG, + "onNewEndpoint instance=$instance temporary=${endpoint.temporary} hasPubKeySet=${endpoint.pubKeySet != null} url=${ + endpoint.url.take( + 120 + ) + }" + ) + + unifiedPushManager.onNewEndpoint(endpoint) + + if (!isUnifiedPushSelected()) return + pushSyncTrigger.requestSync("unified_push_new_endpoint") + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) { + Log.w(TAG, "onRegistrationFailed instance=$instance reason=$reason") + unifiedPushManager.onRegistrationFailed(reason) + } + + override fun onUnregistered(instance: String) { + Log.w(TAG, "onUnregistered instance=$instance") + unifiedPushManager.onUnregistered() + } + + override fun onTempUnavailable(instance: String) { + unifiedPushManager.onTempUnavailable() + Log.w(TAG, "UnifiedPush temp unavailable for instance=$instance") + } + + private fun isUnifiedPushSelected(): Boolean { + return appPreferences.pushProvider.value == PushProvider.UNIFIED_PUSH + } + + private companion object { + const val TAG = "UnifiedPushService" + } +} diff --git a/data/src/test/java/org/monogram/data/proxy/MtprotoSecretNormalizerTest.kt b/data/src/test/java/org/monogram/data/proxy/MtprotoSecretNormalizerTest.kt new file mode 100644 index 00000000..19c42bea --- /dev/null +++ b/data/src/test/java/org/monogram/data/proxy/MtprotoSecretNormalizerTest.kt @@ -0,0 +1,45 @@ +package org.monogram.data.proxy + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.monogram.domain.proxy.MtprotoSecretNormalizer + +class MtprotoSecretNormalizerTest { + + @Test + fun `normalize keeps valid hex and lowercases it`() { + val normalized = MtprotoSecretNormalizer.normalize("A1B2c3D4") + assertEquals("a1b2c3d4", normalized) + } + + @Test + fun `normalize decodes base64url and converts to hex`() { + val normalized = MtprotoSecretNormalizer.normalize("7v0mvBQ9yQrMNLtAYp5lHI1wZXRyb3ZpY2gucg") + assertEquals("eefd26bc143dc90acc34bb40629e651c8d706574726f766963682e72", normalized) + } + + @Test + fun `normalize decodes base64 with padding and converts to hex`() { + val normalized = MtprotoSecretNormalizer.normalize("AAE=") + assertEquals("0001", normalized) + } + + @Test + fun `normalize rejects odd-length hex`() { + val normalized = MtprotoSecretNormalizer.normalize("abc") + assertNull(normalized) + } + + @Test + fun `normalize rejects invalid secret`() { + val normalized = MtprotoSecretNormalizer.normalize("not a secret !") + assertNull(normalized) + } + + @Test + fun `isValid returns false for blank input`() { + assertFalse(MtprotoSecretNormalizer.isValid(" ")) + } +} diff --git a/domain/src/main/java/org/monogram/domain/managers/DistrManager.kt b/domain/src/main/java/org/monogram/domain/managers/DistrManager.kt index b40ef808..a0a0842a 100644 --- a/domain/src/main/java/org/monogram/domain/managers/DistrManager.kt +++ b/domain/src/main/java/org/monogram/domain/managers/DistrManager.kt @@ -3,5 +3,6 @@ package org.monogram.domain.managers interface DistrManager { fun isGmsAvailable(): Boolean fun isFcmAvailable(): Boolean + fun isUnifiedPushDistributorAvailable(): Boolean fun isInstalledFromGooglePlay(): Boolean } \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/proxy/MtprotoSecretNormalizer.kt b/domain/src/main/java/org/monogram/domain/proxy/MtprotoSecretNormalizer.kt new file mode 100644 index 00000000..6daf6977 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/proxy/MtprotoSecretNormalizer.kt @@ -0,0 +1,43 @@ +package org.monogram.domain.proxy + +import java.util.Base64 + +object MtprotoSecretNormalizer { + private val hexRegex = Regex("^[0-9a-fA-F]+$") + + fun normalize(secret: String): String? { + val candidate = secret.trim() + if (candidate.isEmpty()) return null + + if (hexRegex.matches(candidate)) { + if (candidate.length % 2 != 0) return null + return candidate.lowercase() + } + + decodeBase64Like(candidate)?.let { decoded -> + if (decoded.isNotEmpty()) return decoded.toHexLowercase() + } + + return null + } + + fun isValid(secret: String): Boolean = normalize(secret) != null + + private fun decodeBase64Like(value: String): ByteArray? { + val padded = value.padEnd(value.length + ((4 - value.length % 4) % 4), '=') + return runCatching { Base64.getUrlDecoder().decode(padded) } + .recoverCatching { Base64.getDecoder().decode(padded) } + .getOrNull() + } + + private fun ByteArray.toHexLowercase(): String { + val hexDigits = "0123456789abcdef" + val chars = CharArray(size * 2) + forEachIndexed { index, byte -> + val value = byte.toInt() and 0xFF + chars[index * 2] = hexDigits[value ushr 4] + chars[index * 2 + 1] = hexDigits[value and 0x0F] + } + return String(chars) + } +} diff --git a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt index 5de4c8ce..7449b32c 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt @@ -3,7 +3,7 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.StateFlow enum class PushProvider { - FCM, GMS_LESS + FCM, UNIFIED_PUSH, GMS_LESS } enum class ProxyNetworkType { diff --git a/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt index 6678d0a5..af7d7be4 100644 --- a/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt @@ -3,6 +3,11 @@ package org.monogram.domain.repository import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel +sealed interface ProxyTestResult { + data class Success(val ping: Long) : ProxyTestResult + data class Failure(val message: String) : ProxyTestResult +} + interface ExternalProxyRepository { suspend fun getProxies(): List suspend fun addProxy(server: String, port: Int, enable: Boolean, type: ProxyTypeModel): ProxyModel? @@ -11,6 +16,8 @@ interface ExternalProxyRepository { suspend fun disableProxy(): Boolean suspend fun removeProxy(proxyId: Int): Boolean suspend fun pingProxy(proxyId: Int): Long? + suspend fun pingProxyDetailed(proxyId: Int): ProxyTestResult suspend fun testProxy(server: String, port: Int, type: ProxyTypeModel): Long? + suspend fun testProxyDetailed(server: String, port: Int, type: ProxyTypeModel): ProxyTestResult fun setPreferIpv6(enabled: Boolean) } diff --git a/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt b/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt new file mode 100644 index 00000000..856d2f55 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt @@ -0,0 +1,31 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.StateFlow + +enum class UnifiedPushDebugStatus { + IDLE, + REGISTERING, + REGISTERED, + FAILED, + UNREGISTERED +} + +data class PushDiagnostics( + val pushProvider: PushProvider = PushProvider.FCM, + val backgroundServiceEnabled: Boolean = true, + val hideForegroundNotification: Boolean = false, + val isPowerSavingMode: Boolean = false, + val isWakeLockEnabled: Boolean = true, + val batteryOptimizationEnabled: Boolean = false, + val isTdNotificationServiceRunning: Boolean = false, + val unifiedPushStatus: UnifiedPushDebugStatus = UnifiedPushDebugStatus.IDLE, + val unifiedPushEndpoint: String? = null, + val unifiedPushSavedDistributor: String? = null, + val unifiedPushAckDistributor: String? = null, + val unifiedPushDistributorsCount: Int = 0 +) + +interface PushDebugRepository { + val diagnostics: StateFlow + fun triggerTestPush() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76cc6af3..c7997585 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,7 @@ playServices-ossLicenses = "17.5.0" zxing = "3.5.4" junit = "4.13.2" libphonenumber = "9.0.27" +unifiedpush-connector = "3.3.2" [libraries] # AndroidX Activity @@ -140,6 +141,7 @@ maplibre-compose = { module = "io.github.rallista:maplibre-compose", version.ref zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } junit = { group = "junit", name = "junit", version.ref = "junit" } libphonenumber = { module = "com.googlecode.libphonenumber:libphonenumber", version.ref = "libphonenumber" } +unifiedpush-connector = { group = "org.unifiedpush.android", name = "connector", version.ref = "unifiedpush-connector" } [bundles] androidx-camera = [ diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index af285278..9a485ecd 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { implementation(libs.maplibre.compose) implementation(libs.play.services.oss.licenses) implementation(libs.play.services.location) + implementation(libs.unifiedpush.connector) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt index 5b666ca0..6af73078 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt @@ -296,8 +296,7 @@ class AppPreferences( private val _showSenderOnly = MutableStateFlow(prefs.getBoolean(KEY_SHOW_SENDER_ONLY, false)) override val showSenderOnly: StateFlow = _showSenderOnly - private val _pushProvider = - MutableStateFlow(PushProvider.entries[prefs.getInt(KEY_PUSH_PROVIDER, PushProvider.FCM.ordinal)]) + private val _pushProvider = MutableStateFlow(loadPushProvider()) override val pushProvider: StateFlow = _pushProvider private val _isArchivePinned = MutableStateFlow(prefs.getBoolean(KEY_IS_ARCHIVE_PINNED, true)) @@ -893,7 +892,10 @@ class AppPreferences( } override fun setPushProvider(provider: PushProvider) { - prefs.edit().putInt(KEY_PUSH_PROVIDER, provider.ordinal).apply() + prefs.edit() + .putString(KEY_PUSH_PROVIDER_NAME, provider.name) + .putInt(KEY_PUSH_PROVIDER, provider.ordinal) + .apply() _pushProvider.value = provider } @@ -1144,6 +1146,24 @@ class AppPreferences( _isSupportViewed.value = viewed } + private fun loadPushProvider(): PushProvider { + val byName = prefs.getString(KEY_PUSH_PROVIDER_NAME, null) + ?.let { stored -> PushProvider.entries.firstOrNull { it.name == stored } } + if (byName != null) { + return byName + } + + val legacyOrdinal = prefs.getInt(KEY_PUSH_PROVIDER, PushProvider.FCM.ordinal) + val migrated = when (legacyOrdinal) { + 0 -> PushProvider.FCM + 1 -> PushProvider.GMS_LESS + else -> PushProvider.entries.getOrNull(legacyOrdinal) ?: PushProvider.FCM + } + + prefs.edit().putString(KEY_PUSH_PROVIDER_NAME, migrated.name).apply() + return migrated + } + companion object { private const val KEY_FONT_SIZE = "font_size" private const val KEY_LETTER_SPACING = "letter_spacing" @@ -1226,6 +1246,7 @@ class AppPreferences( private const val KEY_REPEAT_NOTIFICATIONS = "repeat_notifications" private const val KEY_SHOW_SENDER_ONLY = "show_sender_only" private const val KEY_PUSH_PROVIDER = "push_provider" + private const val KEY_PUSH_PROVIDER_NAME = "push_provider_name" private const val KEY_IS_ARCHIVE_PINNED = "is_archive_pinned" private const val KEY_IS_ARCHIVE_ALWAYS_VISIBLE = "is_archive_always_visible" diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/ContextExtensions.kt b/presentation/src/main/java/org/monogram/presentation/core/util/ContextExtensions.kt new file mode 100644 index 00000000..5b6309ec --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/core/util/ContextExtensions.kt @@ -0,0 +1,11 @@ +package org.monogram.presentation.core.util + +import android.content.Context +import android.content.ContextWrapper +import androidx.activity.ComponentActivity + +fun Context.findActivity(): ComponentActivity? = when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt index dc356e6f..f5187e46 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt @@ -4,8 +4,56 @@ import coil3.ImageLoader import kotlinx.coroutines.CoroutineScope import org.monogram.core.DispatcherProvider import org.monogram.core.Logger -import org.monogram.domain.managers.* -import org.monogram.domain.repository.* +import org.monogram.domain.managers.AssetsManager +import org.monogram.domain.managers.ClipManager +import org.monogram.domain.managers.DistrManager +import org.monogram.domain.managers.DomainManager +import org.monogram.domain.managers.PhoneManager +import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.AttachMenuBotRepository +import org.monogram.domain.repository.AuthRepository +import org.monogram.domain.repository.BotPreferencesProvider +import org.monogram.domain.repository.BotRepository +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.ChatCreationRepository +import org.monogram.domain.repository.ChatEventLogRepository +import org.monogram.domain.repository.ChatFolderRepository +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository +import org.monogram.domain.repository.ChatOperationsRepository +import org.monogram.domain.repository.ChatSearchRepository +import org.monogram.domain.repository.ChatSettingsRepository +import org.monogram.domain.repository.ChatStatisticsRepository +import org.monogram.domain.repository.EditorSnippetProvider +import org.monogram.domain.repository.EmojiRepository +import org.monogram.domain.repository.ExternalNavigator +import org.monogram.domain.repository.ExternalProxyRepository +import org.monogram.domain.repository.FileRepository +import org.monogram.domain.repository.ForumTopicsRepository +import org.monogram.domain.repository.GifRepository +import org.monogram.domain.repository.InlineBotRepository +import org.monogram.domain.repository.LinkHandlerRepository +import org.monogram.domain.repository.LocationRepository +import org.monogram.domain.repository.MessageAiRepository +import org.monogram.domain.repository.MessageDisplayer +import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.NetworkStatisticsRepository +import org.monogram.domain.repository.NotificationSettingsRepository +import org.monogram.domain.repository.PaymentRepository +import org.monogram.domain.repository.PremiumRepository +import org.monogram.domain.repository.PrivacyRepository +import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.PushDebugRepository +import org.monogram.domain.repository.SessionRepository +import org.monogram.domain.repository.SponsorRepository +import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.StorageRepository +import org.monogram.domain.repository.StringProvider +import org.monogram.domain.repository.UpdateRepository +import org.monogram.domain.repository.UserProfileEditRepository +import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.WallpaperRepository +import org.monogram.domain.repository.WebAppRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache @@ -64,6 +112,7 @@ interface RepositoriesContainer { val gifRepository: GifRepository val emojiRepository: EmojiRepository val updateRepository: UpdateRepository + val pushDebugRepository: PushDebugRepository } interface UtilsContainer { diff --git a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt index 50f44bd9..b8291666 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt @@ -5,8 +5,56 @@ import kotlinx.coroutines.CoroutineScope import org.koin.core.Koin import org.monogram.core.DispatcherProvider import org.monogram.core.Logger -import org.monogram.domain.managers.* -import org.monogram.domain.repository.* +import org.monogram.domain.managers.AssetsManager +import org.monogram.domain.managers.ClipManager +import org.monogram.domain.managers.DistrManager +import org.monogram.domain.managers.DomainManager +import org.monogram.domain.managers.PhoneManager +import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.AttachMenuBotRepository +import org.monogram.domain.repository.AuthRepository +import org.monogram.domain.repository.BotPreferencesProvider +import org.monogram.domain.repository.BotRepository +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.ChatCreationRepository +import org.monogram.domain.repository.ChatEventLogRepository +import org.monogram.domain.repository.ChatFolderRepository +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository +import org.monogram.domain.repository.ChatOperationsRepository +import org.monogram.domain.repository.ChatSearchRepository +import org.monogram.domain.repository.ChatSettingsRepository +import org.monogram.domain.repository.ChatStatisticsRepository +import org.monogram.domain.repository.EditorSnippetProvider +import org.monogram.domain.repository.EmojiRepository +import org.monogram.domain.repository.ExternalNavigator +import org.monogram.domain.repository.ExternalProxyRepository +import org.monogram.domain.repository.FileRepository +import org.monogram.domain.repository.ForumTopicsRepository +import org.monogram.domain.repository.GifRepository +import org.monogram.domain.repository.InlineBotRepository +import org.monogram.domain.repository.LinkHandlerRepository +import org.monogram.domain.repository.LocationRepository +import org.monogram.domain.repository.MessageAiRepository +import org.monogram.domain.repository.MessageDisplayer +import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.NetworkStatisticsRepository +import org.monogram.domain.repository.NotificationSettingsRepository +import org.monogram.domain.repository.PaymentRepository +import org.monogram.domain.repository.PremiumRepository +import org.monogram.domain.repository.PrivacyRepository +import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.PushDebugRepository +import org.monogram.domain.repository.SessionRepository +import org.monogram.domain.repository.SponsorRepository +import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.StorageRepository +import org.monogram.domain.repository.StringProvider +import org.monogram.domain.repository.UpdateRepository +import org.monogram.domain.repository.UserProfileEditRepository +import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.WallpaperRepository +import org.monogram.domain.repository.WebAppRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache @@ -65,6 +113,7 @@ class KoinRepositoriesContainer(private val koin: Koin) : RepositoriesContainer override val gifRepository: GifRepository by lazy { koin.get() } override val emojiRepository: EmojiRepository by lazy { koin.get() } override val updateRepository: UpdateRepository by lazy { koin.get() } + override val pushDebugRepository: PushDebugRepository by lazy { koin.get() } } class KoinUtilsContainer(private val koin: Koin) : UtilsContainer { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt index 4b95d14d..dd059a11 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt @@ -1,12 +1,18 @@ package org.monogram.presentation.features.chats +import androidx.compose.runtime.Immutable import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.models.* import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.core.util.AppPreferences interface ChatListComponent { - val state: StateFlow + val uiState: StateFlow + val foldersState: StateFlow + val chatsState: StateFlow + val selectionState: StateFlow + val searchState: StateFlow + val appPreferences: AppPreferences fun onChatClicked(id: Long) @@ -52,26 +58,14 @@ interface ChatListComponent { fun updateScrollPosition(folderId: Int, index: Int, offset: Int) - data class State( - val chatsByFolder: Map> = emptyMap(), - val folders: List = emptyList(), - val selectedFolderId: Int = -1, + @Immutable + data class UiState( val currentUser: UserModel? = null, - val isLoadingByFolder: Map = emptyMap(), - val selectedChatIds: Set = emptySet(), - val isSearchActive: Boolean = false, - val searchQuery: String = "", - val searchResults: List = emptyList(), - val globalSearchResults: List = emptyList(), - val messageSearchResults: List = emptyList(), - val searchHistory: List = emptyList(), val connectionStatus: ConnectionStatus = ConnectionStatus.Connected, val isArchivePinned: Boolean = true, val isArchiveAlwaysVisible: Boolean = false, val isForwarding: Boolean = false, - val canLoadMoreMessages: Boolean = false, val instantViewUrl: String? = null, - val activeChatId: Long? = null, val isProxyEnabled: Boolean = false, val attachMenuBots: List = emptyList(), val botWebAppUrl: String? = null, @@ -80,10 +74,38 @@ interface ChatListComponent { val webAppBotId: Long? = null, val webAppBotName: String? = null, val webViewUrl: String? = null, - val updateState: UpdateState = UpdateState.Idle, + val updateState: UpdateState = UpdateState.Idle + ) + + @Immutable + data class FoldersState( + val chatsByFolder: Map> = emptyMap(), + val folders: List = emptyList(), + val selectedFolderId: Int = -1, + val isLoadingByFolder: Map = emptyMap(), val scrollPositions: Map> = emptyMap() - ) { - val chats: List get() = chatsByFolder[selectedFolderId] ?: emptyList() - val isLoading: Boolean get() = isLoadingByFolder[selectedFolderId] ?: false - } + ) + + data class ChatsState( + val chats: List = emptyList(), + val isLoading: Boolean = false + ) + + @Immutable + data class SelectionState( + val selectedChatIds: Set = emptySet(), + val activeChatId: Long? = null + ) + + @Immutable + data class SearchState( + val isSearchActive: Boolean = false, + val searchQuery: String = "", + val searchResults: List = emptyList(), + val globalSearchResults: List = emptyList(), + val messageSearchResults: List = emptyList(), + val recentUsers: List = emptyList(), + val recentOthers: List = emptyList(), + val canLoadMoreMessages: Boolean = false + ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt deleted file mode 100644 index 6a894216..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.monogram.presentation.features.chats - -import com.arkivanov.mvikotlin.core.store.Store - -interface ChatListStore : Store { - - sealed class Intent { - data class ChatClicked(val id: Long) : Intent() - data class ProfileClicked(val id: Long) : Intent() - data class MessageClicked(val chatId: Long, val messageId: Long) : Intent() - object SettingsClicked : Intent() - data class FolderClicked(val id: Int) : Intent() - data class LoadMore(val folderId: Int? = null) : Intent() - object LoadMoreMessages : Intent() - data class ChatLongClicked(val id: Long) : Intent() - object ClearSelection : Intent() - object RetryConnection : Intent() - object SearchToggle : Intent() - data class SearchQueryChange(val query: String) : Intent() - object ClearSearchHistory : Intent() - data class RemoveSearchHistoryItem(val chatId: Long) : Intent() - data class MuteSelected(val mute: Boolean) : Intent() - data class ArchiveSelected(val archive: Boolean) : Intent() - object DeleteSelected : Intent() - object ArchivePinToggle : Intent() - object ConfirmForwarding : Intent() - object NewChatClicked : Intent() - object ProxySettingsClicked : Intent() - data class OpenInstantView(val url: String) : Intent() - object DismissInstantView : Intent() - data class OpenWebApp(val url: String, val botUserId: Long, val botName: String) : Intent() - object DismissWebApp : Intent() - data class OpenWebView(val url: String) : Intent() - object DismissWebView : Intent() - object UpdateClicked : Intent() - data class UpdateScrollPosition(val folderId: Int, val index: Int, val offset: Int) : Intent() - data class UpdateState(val state: ChatListComponent.State) : Intent() - } - - sealed class Label { - data class ChatClicked(val id: Long) : Label() - data class ProfileClicked(val id: Long) : Label() - data class MessageClicked(val chatId: Long, val messageId: Long) : Label() - object SettingsClicked : Label() - object NewChatClicked : Label() - object ProxySettingsClicked : Label() - } -} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt deleted file mode 100644 index 6f1e9485..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.monogram.presentation.features.chats - -import com.arkivanov.mvikotlin.core.store.Reducer -import com.arkivanov.mvikotlin.core.store.Store -import com.arkivanov.mvikotlin.core.store.StoreFactory -import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor -import org.monogram.presentation.features.chats.ChatListStore.Intent -import org.monogram.presentation.features.chats.ChatListStore.Label -import org.monogram.presentation.features.chats.chatList.DefaultChatListComponent - -class ChatListStoreFactory( - private val storeFactory: StoreFactory, - private val component: DefaultChatListComponent -) { - - fun create(): ChatListStore = - object : ChatListStore, Store by storeFactory.create( - name = "ChatListStore", - initialState = ChatListComponent.State(isForwarding = component.isForwarding), - executorFactory = ::ExecutorImpl, - reducer = ReducerImpl - ) {} - - private inner class ExecutorImpl : CoroutineExecutor() { - override fun executeIntent(intent: Intent) { - when (intent) { - is Intent.ChatClicked -> component.onChatClicked(intent.id) - is Intent.ProfileClicked -> component.onProfileClicked(intent.id) - is Intent.MessageClicked -> component.onMessageClicked(intent.chatId, intent.messageId) - Intent.SettingsClicked -> component.onSettingsClicked() - is Intent.FolderClicked -> component.onFolderClicked(intent.id) - is Intent.LoadMore -> component.loadMore(intent.folderId) - Intent.LoadMoreMessages -> component.loadMoreMessages() - is Intent.ChatLongClicked -> component.onChatLongClicked(id = intent.id) - Intent.ClearSelection -> component.clearSelection() - Intent.RetryConnection -> component.retryConnection() - Intent.SearchToggle -> component.onSearchToggle() - is Intent.SearchQueryChange -> component.onSearchQueryChange(intent.query) - Intent.ClearSearchHistory -> component.onClearSearchHistory() - is Intent.RemoveSearchHistoryItem -> component.onRemoveSearchHistoryItem(intent.chatId) - is Intent.MuteSelected -> component.onMuteSelected(intent.mute) - is Intent.ArchiveSelected -> component.onArchiveSelected(archive = intent.archive) - Intent.DeleteSelected -> component.onDeleteSelected() - Intent.ArchivePinToggle -> component.onArchivePinToggle() - Intent.ConfirmForwarding -> component.onConfirmForwarding() - Intent.NewChatClicked -> component.onNewChatClicked() - Intent.ProxySettingsClicked -> component.onProxySettingsClicked() - is Intent.OpenInstantView -> component.onOpenInstantView(intent.url) - Intent.DismissInstantView -> component.onDismissInstantView() - is Intent.OpenWebApp -> component.onOpenWebApp(intent.url, intent.botUserId, intent.botName) - Intent.DismissWebApp -> component.onDismissWebApp() - is Intent.OpenWebView -> component.onOpenWebView(intent.url) - Intent.DismissWebView -> component.onDismissWebView() - Intent.UpdateClicked -> component.onUpdateClicked() - is Intent.UpdateScrollPosition -> component.updateScrollPosition( - intent.folderId, - intent.index, - intent.offset - ) - - is Intent.UpdateState -> dispatch(Message.UpdateState(intent.state)) - } - } - } - - private object ReducerImpl : Reducer { - override fun ChatListComponent.State.reduce(msg: Message): ChatListComponent.State = - when (msg) { - is Message.UpdateState -> msg.state - } - } - - sealed class Message { - data class UpdateState(val state: ChatListComponent.State) : Message() - } -} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt index 882033d1..896aa2a8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt @@ -113,7 +113,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.koin.compose.koinInject -import org.monogram.domain.models.ChatType import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar @@ -140,7 +139,12 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatListContent(component: ChatListComponent) { - val state by component.state.collectAsState() + val uiState by component.uiState.collectAsState() + val foldersState by component.foldersState.collectAsState() + val chatsState by component.chatsState.collectAsState() + val selectionState by component.selectionState.collectAsState() + val searchState by component.searchState.collectAsState() + val scope = rememberCoroutineScope() val haptic = LocalHapticFeedback.current @@ -164,7 +168,7 @@ fun ChatListContent(component: ChatListComponent) { adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) && isTabletInterfaceEnabled val isCustomBackHandlingEnabled = - state.isSearchActive || state.selectedChatIds.isNotEmpty() || state.selectedFolderId == -2 || state.isForwarding || state.instantViewUrl != null || state.webAppUrl != null || state.webViewUrl != null || showStatusMenu + searchState.isSearchActive || selectionState.selectedChatIds.isNotEmpty() || foldersState.selectedFolderId == -2 || uiState.isForwarding || uiState.instantViewUrl != null || uiState.webAppUrl != null || uiState.webViewUrl != null || showStatusMenu BackHandler(enabled = isCustomBackHandlingEnabled) { if (showStatusMenu) { @@ -175,26 +179,26 @@ fun ChatListContent(component: ChatListComponent) { } val pagerState = rememberPagerState( - initialPage = state.folders.indexOfFirst { it.id == state.selectedFolderId }.coerceAtLeast(0), - pageCount = { state.folders.size } + initialPage = foldersState.folders.indexOfFirst { it.id == foldersState.selectedFolderId }.coerceAtLeast(0), + pageCount = { foldersState.folders.size } ) - LaunchedEffect(state.selectedFolderId) { - val index = state.folders.indexOfFirst { it.id == state.selectedFolderId }.coerceAtLeast(0) + LaunchedEffect(foldersState.selectedFolderId) { + val index = foldersState.folders.indexOfFirst { it.id == foldersState.selectedFolderId }.coerceAtLeast(0) if (pagerState.currentPage != index) pagerState.animateScrollToPage(index) } LaunchedEffect(pagerState.currentPage) { - if (state.folders.isNotEmpty()) { - val folderId = state.folders[pagerState.currentPage].id - if (state.selectedFolderId != folderId && state.selectedFolderId != -2) { + if (foldersState.folders.isNotEmpty()) { + val folderId = foldersState.folders[pagerState.currentPage].id + if (foldersState.selectedFolderId != folderId && foldersState.selectedFolderId != -2) { component.onFolderClicked(folderId) } } } val density = LocalDensity.current - val tabsHeight = if (state.folders.size > 1) 56.dp else 10.dp + val tabsHeight = if (foldersState.folders.size > 1) 56.dp else 10.dp val archiveItemHeight = 78.dp val tabsHeightPx = with(density) { tabsHeight.toPx() } val archiveItemHeightPx = with(density) { archiveItemHeight.toPx() } @@ -208,10 +212,10 @@ fun ChatListContent(component: ChatListComponent) { var hasVibrated by remember { mutableStateOf(false) } var canRevealArchive by remember { mutableStateOf(true) } - val currentFolder = state.folders.getOrNull(pagerState.currentPage) + val currentFolder = foldersState.folders.getOrNull(pagerState.currentPage) val isMainFolder = currentFolder?.id == -1 - val isArchivePersistent = state.isArchivePinned && (state.isArchiveAlwaysVisible || isMainFolder) + val isArchivePersistent = uiState.isArchivePinned && (uiState.isArchiveAlwaysVisible || isMainFolder) val canShowArchive = isArchivePersistent || isMainFolder val lastArchivePersistent = remember { mutableStateOf(isArchivePersistent) } @@ -263,7 +267,7 @@ fun ChatListContent(component: ChatListComponent) { } } - val nestedScrollConnection = remember(isArchivePersistent, canShowArchive, state.isArchiveAlwaysVisible, tabsHeightPx) { + val nestedScrollConnection = remember(isArchivePersistent, canShowArchive, uiState.isArchiveAlwaysVisible, tabsHeightPx) { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { if (source == NestedScrollSource.UserInput) { @@ -308,7 +312,7 @@ fun ChatListContent(component: ChatListComponent) { } var limit = 0f - if (isArchivePersistent && !state.isArchiveAlwaysVisible) { + if (isArchivePersistent && !uiState.isArchiveAlwaysVisible) { limit = -archiveItemHeightPx } @@ -399,19 +403,19 @@ fun ChatListContent(component: ChatListComponent) { val isFabExpanded by remember { derivedStateOf { headerOffsetPx > -10f } } - var cachedStatusEmojiPath by remember(state.currentUser?.id) { - mutableStateOf(state.currentUser?.statusEmojiPath) + var cachedStatusEmojiPath by remember(uiState.currentUser?.id) { + mutableStateOf(uiState.currentUser?.statusEmojiPath) } - LaunchedEffect(state.currentUser?.id, state.currentUser?.statusEmojiPath) { - val statusEmojiPath = state.currentUser?.statusEmojiPath + LaunchedEffect(uiState.currentUser?.id, uiState.currentUser?.statusEmojiPath) { + val statusEmojiPath = uiState.currentUser?.statusEmojiPath if (!statusEmojiPath.isNullOrBlank()) { cachedStatusEmojiPath = statusEmojiPath } } - val currentUser = remember(state.currentUser, cachedStatusEmojiPath) { - state.currentUser?.let { user -> + val currentUser = remember(uiState.currentUser, cachedStatusEmojiPath) { + uiState.currentUser?.let { user -> if (user.statusEmojiId != 0L && user.statusEmojiPath.isNullOrBlank() && !cachedStatusEmojiPath.isNullOrBlank()) { user.copy(statusEmojiPath = cachedStatusEmojiPath) } else { @@ -423,7 +427,7 @@ fun ChatListContent(component: ChatListComponent) { if (showAccountMenu) { AccountMenu( user = currentUser, - attachMenuBots = state.attachMenuBots, + attachMenuBots = uiState.attachMenuBots, onDismiss = { showAccountMenu = false }, onSavedMessagesClick = { currentUser?.id?.let { component.onChatClicked(it) } @@ -436,13 +440,13 @@ fun ChatListContent(component: ChatListComponent) { onProfileClick = { currentUser?.id?.let { component.onProfileClicked(it) } }, - updateState = state.updateState, + updateState = uiState.updateState, onUpdateClick = { component.onUpdateClicked() }, onBotClick = { bot -> component.onOpenWebApp( - url = state.botWebAppUrl ?: "", + url = uiState.botWebAppUrl ?: "", botUserId = bot.botUserId, - botName = state.botWebAppName ?: bot.name + botName = uiState.botWebAppName ?: bot.name ) } ) @@ -478,19 +482,19 @@ fun ChatListContent(component: ChatListComponent) { topBar = { Column(Modifier.fillMaxWidth()) { AnimatedContent( - targetState = state.selectedChatIds.isNotEmpty() && !state.isForwarding, + targetState = selectionState.selectedChatIds.isNotEmpty() && !uiState.isForwarding, label = "TopBarSelectionAnimation", transitionSpec = { fadeIn() togetherWith fadeOut() } ) { isSelectionMode -> if (isSelectionMode) { - val selectedChats = state.chats.filter { state.selectedChatIds.contains(it.id) } + val selectedChats = chatsState.chats.filter { selectionState.selectedChatIds.contains(it.id) } val canMarkUnread = selectedChats.any { !it.isMarkedAsUnread } val allPinned = selectedChats.isNotEmpty() && selectedChats.all { it.isPinned } val allMuted = selectedChats.isNotEmpty() && selectedChats.all { it.isMuted } - val isInArchive = state.selectedFolderId == -2 + val isInArchive = foldersState.selectedFolderId == -2 SelectionTopBar( - selectedCount = state.selectedChatIds.size, + selectedCount = selectionState.selectedChatIds.size, isInArchive = isInArchive, allPinned = allPinned, allMuted = allMuted, @@ -503,7 +507,7 @@ fun ChatListContent(component: ChatListComponent) { canMarkUnread = canMarkUnread ) } else { - if (state.isForwarding) { + if (uiState.isForwarding) { TopAppBar( title = { Column { @@ -512,11 +516,11 @@ fun ChatListContent(component: ChatListComponent) { fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium ) - if (state.selectedChatIds.isNotEmpty()) { + if (selectionState.selectedChatIds.isNotEmpty()) { Text( text = stringResource( R.string.chats_selected_format, - state.selectedChatIds.size + selectionState.selectedChatIds.size ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary @@ -530,7 +534,7 @@ fun ChatListContent(component: ChatListComponent) { } }, actions = { - if (state.selectedChatIds.isNotEmpty()) { + if (selectionState.selectedChatIds.isNotEmpty()) { IconButton(onClick = { component.onConfirmForwarding() }) { Icon( Icons.AutoMirrored.Rounded.Send, @@ -542,7 +546,7 @@ fun ChatListContent(component: ChatListComponent) { }, colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) ) - } else if (state.selectedFolderId == -2 && !state.isSearchActive) { + } else if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) { TopAppBar( title = { Text( @@ -568,12 +572,12 @@ fun ChatListContent(component: ChatListComponent) { } else { ChatListTopBar( user = currentUser, - connectionStatus = state.connectionStatus, - isProxyEnabled = state.isProxyEnabled, + connectionStatus = uiState.connectionStatus, + isProxyEnabled = uiState.isProxyEnabled, onRetryConnection = { component.retryConnection() }, onProxySettingsClick = { component.onProxySettingsClicked() }, - isSearchActive = state.isSearchActive, - searchQuery = state.searchQuery, + isSearchActive = searchState.isSearchActive, + searchQuery = searchState.searchQuery, onSearchQueryChange = component::onSearchQueryChange, onSearchToggle = component::onSearchToggle, onStatusClick = { anchorBounds -> @@ -586,7 +590,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.connectionStatus == ConnectionStatus.Connecting || state.connectionStatus == ConnectionStatus.Updating || state.connectionStatus == ConnectionStatus.ConnectingToProxy) { + if (uiState.connectionStatus == ConnectionStatus.Connecting || uiState.connectionStatus == ConnectionStatus.Updating || uiState.connectionStatus == ConnectionStatus.ConnectingToProxy) { Column { LinearWavyProgressIndicator( modifier = Modifier @@ -598,7 +602,7 @@ fun ChatListContent(component: ChatListComponent) { } } - val isMainView = !state.isSearchActive && state.selectedFolderId != -2 + val isMainView = !searchState.isSearchActive && foldersState.selectedFolderId != -2 if (isMainView) { Box( @@ -656,7 +660,7 @@ fun ChatListContent(component: ChatListComponent) { } ) { ArchiveHeaderCard( - isPinned = state.isArchivePinned, + isPinned = uiState.isArchivePinned, onClick = { component.onFolderClicked(-2) }, onLongClick = { component.onArchivePinToggle() } ) @@ -664,14 +668,14 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.folders.size > 1) { + if (foldersState.folders.size > 1) { FolderTabs( modifier = Modifier, - folders = state.folders, + folders = foldersState.folders, pagerState = pagerState, onTabClick = { index -> if (pagerState.currentPage == index) { - val folderId = state.folders[index].id + val folderId = foldersState.folders[index].id scope.launch { scrollStates[folderId]?.animateScrollToItem(0) } @@ -695,7 +699,7 @@ fun ChatListContent(component: ChatListComponent) { floatingActionButton = { if (!isTablet) { AnimatedVisibility( - visible = !state.isSearchActive && state.selectedFolderId != -2 && !state.isForwarding, + visible = !searchState.isSearchActive && foldersState.selectedFolderId != -2 && !uiState.isForwarding, enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut() ) { @@ -710,7 +714,7 @@ fun ChatListContent(component: ChatListComponent) { } AnimatedVisibility( - visible = state.isForwarding && state.selectedChatIds.isNotEmpty(), + visible = uiState.isForwarding && selectionState.selectedChatIds.isNotEmpty(), enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut() ) { @@ -737,32 +741,32 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier .fillMaxSize() ) { - if (state.isSearchActive || state.selectedFolderId == -2) { + if (searchState.isSearchActive || foldersState.selectedFolderId == -2) { var showAllGlobal by remember { mutableStateOf(false) } var showAllMessages by remember { mutableStateOf(false) } val scrollState = rememberLazyListState( - initialFirstVisibleItemIndex = if (state.selectedFolderId == -2 && !state.isSearchActive) state.scrollPositions[-2]?.first ?: 0 else 0, - initialFirstVisibleItemScrollOffset = if (state.selectedFolderId == -2 && !state.isSearchActive) state.scrollPositions[-2]?.second ?: 0 else 0 + initialFirstVisibleItemIndex = if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) foldersState.scrollPositions[-2]?.first ?: 0 else 0, + initialFirstVisibleItemScrollOffset = if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) foldersState.scrollPositions[-2]?.second ?: 0 else 0 ) - if (state.selectedFolderId == -2 && !state.isSearchActive) { + if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) { scrollStates[-2] = scrollState } - val firstItemId = if (state.selectedFolderId == -2 && !state.isSearchActive) { - state.chatsByFolder[-2]?.firstOrNull()?.id + val firstItemId = if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) { + foldersState.chatsByFolder[-2]?.firstOrNull()?.id } else { null } LaunchedEffect(firstItemId) { - if (state.selectedFolderId == -2 && !state.isSearchActive && !scrollState.isScrollInProgress && scrollState.firstVisibleItemIndex <= 1) { + if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive && !scrollState.isScrollInProgress && scrollState.firstVisibleItemIndex <= 1) { scrollState.scrollToItem(0, 0) } } - if (state.selectedFolderId == -2 && !state.isSearchActive) { + if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) { DisposableEffect(Unit) { onDispose { component.updateScrollPosition(-2, scrollState.firstVisibleItemIndex, scrollState.firstVisibleItemScrollOffset) @@ -770,10 +774,10 @@ fun ChatListContent(component: ChatListComponent) { } } - val isArchivedView = state.selectedFolderId == -2 && !state.isSearchActive - val archivedChats = if (isArchivedView) state.chatsByFolder[-2] ?: emptyList() else emptyList() - val isArchivedLoading = if (isArchivedView) state.isLoadingByFolder[-2] ?: false else false - val hasArchivedLoadState = if (isArchivedView) state.isLoadingByFolder.containsKey(-2) else false + val isArchivedView = foldersState.selectedFolderId == -2 && !searchState.isSearchActive + val archivedChats = if (isArchivedView) chatsState.chats else emptyList() + val isArchivedLoading = if (isArchivedView) chatsState.isLoading else false + val hasArchivedLoadState = isArchivedView && (foldersState.isLoadingByFolder.containsKey(-2) || chatsState.chats.isNotEmpty()) val showArchivedShimmer = isArchivedView && archivedChats.isEmpty() && (isArchivedLoading || !hasArchivedLoadState) val shouldAnimateFirstArchiveTransition = firstFolderTransitionCompleted[-2] != true @@ -814,13 +818,8 @@ fun ChatListContent(component: ChatListComponent) { end = if (isTablet) 12.dp else 0.dp ), ) { - if (state.isSearchActive) { - if (state.searchQuery.isEmpty() && state.searchHistory.isNotEmpty()) { - val recentUsers = - state.searchHistory.filter { (it.type == ChatType.PRIVATE || it.type == ChatType.SECRET) && !it.isBot } - val recentOthers = - state.searchHistory.filter { it.type != ChatType.PRIVATE && it.type != ChatType.SECRET || it.isBot } - + if (searchState.isSearchActive) { + if (searchState.searchQuery.isEmpty() && (searchState.recentUsers.isNotEmpty() || searchState.recentOthers.isNotEmpty())) { item { Row( modifier = Modifier @@ -841,7 +840,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (recentUsers.isNotEmpty()) { + if (searchState.recentUsers.isNotEmpty()) { item { LazyRow( modifier = Modifier @@ -850,7 +849,7 @@ fun ChatListContent(component: ChatListComponent) { contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - itemsIndexed(items = recentUsers, key = { index, chat -> "recent_user_${chat.id}_$index" }) { _, chat -> + itemsIndexed(items = searchState.recentUsers, key = { index, chat -> "recent_user_${chat.id}_$index" }) { _, chat -> Column( modifier = Modifier .width(64.dp) @@ -908,18 +907,18 @@ fun ChatListContent(component: ChatListComponent) { } } - if (recentOthers.isNotEmpty()) { + if (searchState.recentOthers.isNotEmpty()) { itemsIndexed( - items = recentOthers, + items = searchState.recentOthers, key = { _, chat -> "recent_${chat.id}" }) { _, chat -> ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, + currentUserId = uiState.currentUser?.id, isSelected = false, onClick = { onChatClicked(chat.id) }, onLongClick = { component.onRemoveSearchHistoryItem(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -928,7 +927,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.searchResults.isNotEmpty()) { + if (searchState.searchResults.isNotEmpty()) { item { Text( text = stringResource(R.string.search_section_chats), @@ -937,15 +936,15 @@ fun ChatListContent(component: ChatListComponent) { color = MaterialTheme.colorScheme.primary ) } - itemsIndexed(items = state.searchResults, key = { index, chat -> "search_${chat.id}_$index" }) { _, chat -> + itemsIndexed(items = searchState.searchResults, key = { index, chat -> "search_${chat.id}_$index" }) { _, chat -> ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, - isSelected = state.selectedChatIds.contains(chat.id), + currentUserId = uiState.currentUser?.id, + isSelected = selectionState.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -953,7 +952,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.globalSearchResults.isNotEmpty()) { + if (searchState.globalSearchResults.isNotEmpty()) { item { Text( text = stringResource(R.string.search_section_global), @@ -964,24 +963,24 @@ fun ChatListContent(component: ChatListComponent) { } val globalToDisplay = - if (showAllGlobal) state.globalSearchResults else state.globalSearchResults.take(3) + if (showAllGlobal) searchState.globalSearchResults else searchState.globalSearchResults.take(3) itemsIndexed(items = globalToDisplay, key = { _, chat -> "global_${chat.id}" }) { _, chat -> ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, - isSelected = state.selectedChatIds.contains(chat.id), + currentUserId = uiState.currentUser?.id, + isSelected = selectionState.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos ) } - if (!showAllGlobal && state.globalSearchResults.size > 3) { + if (!showAllGlobal && searchState.globalSearchResults.size > 3) { item { Box( modifier = Modifier @@ -1000,7 +999,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.messageSearchResults.isNotEmpty()) { + if (searchState.messageSearchResults.isNotEmpty()) { item { Text( text = stringResource(R.string.search_section_messages), @@ -1011,12 +1010,12 @@ fun ChatListContent(component: ChatListComponent) { } val messagesToDisplay = - if (showAllMessages) state.messageSearchResults else state.messageSearchResults.take(3) + if (showAllMessages) searchState.messageSearchResults else searchState.messageSearchResults.take(3) itemsIndexed( items = messagesToDisplay, key = { index, msg -> "msg_${msg.id}_$index" }) { index, msg -> - if (showAllMessages && index >= messagesToDisplay.lastIndex - 5 && state.canLoadMoreMessages) { + if (showAllMessages && index >= messagesToDisplay.lastIndex - 5 && searchState.canLoadMoreMessages) { LaunchedEffect(Unit) { component.loadMoreMessages() } } @@ -1027,7 +1026,7 @@ fun ChatListContent(component: ChatListComponent) { ) } - if (!showAllMessages && state.messageSearchResults.size > 3) { + if (!showAllMessages && searchState.messageSearchResults.size > 3) { item { Box( modifier = Modifier @@ -1056,11 +1055,11 @@ fun ChatListContent(component: ChatListComponent) { ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, - isSelected = state.selectedChatIds.contains(chat.id), + currentUserId = uiState.currentUser?.id, + isSelected = selectionState.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -1087,21 +1086,21 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier.fillMaxSize(), beyondViewportPageCount = 1 ) { page -> - val folderId = state.folders.getOrNull(page)?.id - ?: state.folders.firstOrNull { it.id == state.selectedFolderId }?.id + val folderId = foldersState.folders.getOrNull(page)?.id + ?: foldersState.folders.firstOrNull { it.id == foldersState.selectedFolderId }?.id if (folderId == null) { Box(modifier = Modifier.fillMaxSize()) return@HorizontalPager } - val folderChats = state.chatsByFolder[folderId] ?: emptyList() - val isFolderLoading = state.isLoadingByFolder[folderId] ?: false - val hasFolderLoadState = state.isLoadingByFolder.containsKey(folderId) + val folderChats = foldersState.chatsByFolder[folderId] ?: emptyList() + val isFolderLoading = foldersState.isLoadingByFolder[folderId] ?: false + val hasFolderLoadState = foldersState.isLoadingByFolder.containsKey(folderId) val showFolderShimmer = folderChats.isEmpty() && (isFolderLoading || !hasFolderLoadState) val shouldAnimateFirstFolderTransition = firstFolderTransitionCompleted[folderId] != true val scrollState = rememberLazyListState( - initialFirstVisibleItemIndex = state.scrollPositions[folderId]?.first ?: 0, - initialFirstVisibleItemScrollOffset = state.scrollPositions[folderId]?.second ?: 0 + initialFirstVisibleItemIndex = foldersState.scrollPositions[folderId]?.first ?: 0, + initialFirstVisibleItemScrollOffset = foldersState.scrollPositions[folderId]?.second ?: 0 ) scrollStates[folderId] = scrollState @@ -1134,7 +1133,7 @@ fun ChatListContent(component: ChatListComponent) { val isInitialLoad = remember(folderId) { mutableStateOf(true) } LaunchedEffect(folderChats) { if (isInitialLoad.value && folderChats.isNotEmpty()) { - if (state.scrollPositions[folderId] == null) { + if (foldersState.scrollPositions[folderId] == null) { scrollState.scrollToItem(0, 0) } isInitialLoad.value = false @@ -1187,11 +1186,11 @@ fun ChatListContent(component: ChatListComponent) { ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, - isSelected = state.selectedChatIds.contains(chat.id), + currentUserId = uiState.currentUser?.id, + isSelected = selectionState.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -1308,11 +1307,11 @@ fun ChatListContent(component: ChatListComponent) { } AnimatedVisibility( - visible = state.instantViewUrl != null, + visible = uiState.instantViewUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { - state.instantViewUrl?.let { url -> + uiState.instantViewUrl?.let { url -> InstantViewer( url = url, messageRepository = koinInject(), @@ -1324,13 +1323,13 @@ fun ChatListContent(component: ChatListComponent) { } AnimatedVisibility( - visible = state.webAppUrl != null || state.webAppBotId != null, + visible = uiState.webAppUrl != null || uiState.webAppBotId != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { - val webAppUrl = state.webAppUrl - val botUserId = state.webAppBotId - val botName = state.webAppBotName + val webAppUrl = uiState.webAppUrl + val botUserId = uiState.webAppBotId + val botName = uiState.webAppBotName Log.d("MiniAppViewer", "webAppUrl: $webAppUrl, botUserId: $botUserId, botName: $botName") @@ -1349,7 +1348,7 @@ fun ChatListContent(component: ChatListComponent) { if (showDeleteChatsSheet) { ConfirmationSheet( icon = Icons.Rounded.Delete, - title = stringResource(R.string.delete_chats_title, state.selectedChatIds.size), + title = stringResource(R.string.delete_chats_title, selectionState.selectedChatIds.size), description = stringResource(R.string.delete_chats_confirmation), confirmText = stringResource(R.string.action_delete_chats), onConfirm = { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt index b1bef737..c2e63947 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt @@ -2,23 +2,19 @@ package org.monogram.presentation.features.chats.chatList import android.util.Log import com.arkivanov.decompose.value.Value -import com.arkivanov.mvikotlin.core.instancekeeper.getStore -import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow -import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.ChatType import org.monogram.domain.models.UpdateState import org.monogram.domain.repository.* import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.features.chats.ChatListComponent -import org.monogram.presentation.features.chats.ChatListStore -import org.monogram.presentation.features.chats.ChatListStoreFactory import org.monogram.presentation.root.AppComponentContext class DefaultChatListComponent( @@ -45,21 +41,17 @@ class DefaultChatListComponent( private val updateRepository: UpdateRepository = container.repositories.updateRepository override val appPreferences: AppPreferences = container.preferences.appPreferences - private val _state = MutableStateFlow( - ChatListComponent.State( - isForwarding = isForwarding, - isLoadingByFolder = mapOf(-1 to true) - ) - ) - - private val store = instanceKeeper.getStore { - ChatListStoreFactory( - storeFactory = DefaultStoreFactory(), - component = this - ).create() - } + private val _uiState = MutableStateFlow(ChatListComponent.UiState(isForwarding = isForwarding)) + private val _foldersState = MutableStateFlow(ChatListComponent.FoldersState(isLoadingByFolder = mapOf(-1 to true))) + private val _chatsState = MutableStateFlow(ChatListComponent.ChatsState()) + private val _selectionState = MutableStateFlow(ChatListComponent.SelectionState()) + private val _searchState = MutableStateFlow(ChatListComponent.SearchState()) - override val state: StateFlow = store.stateFlow + override val uiState: StateFlow = _uiState.asStateFlow() + override val foldersState: StateFlow = _foldersState.asStateFlow() + override val chatsState: StateFlow = _chatsState + override val selectionState: StateFlow = _selectionState.asStateFlow() + override val searchState: StateFlow = _searchState.asStateFlow() private val scope = componentScope private var searchJob: Job? = null @@ -68,13 +60,13 @@ class DefaultChatListComponent( init { activeChatId.subscribe { id -> - _state.update { it.copy(activeChatId = id) } + _selectionState.update { it.copy(activeChatId = id) } } repositoryUser.currentUserFlow .onEach { user -> if (user != null) { - _state.update { it.copy(currentUser = user) } + _uiState.update { it.copy(currentUser = user) } } } .launchIn(scope) @@ -95,70 +87,90 @@ class DefaultChatListComponent( }" ) } - _state.update { + _foldersState.update { val newChatsByFolder = it.chatsByFolder.toMutableMap() newChatsByFolder[update.folderId] = distinctList it.copy(chatsByFolder = newChatsByFolder) } + + if (update.folderId == _foldersState.value.selectedFolderId) { + _chatsState.update { + it.copy(chats = distinctList) + } + } } .launchIn(scope) chatFolderRepository.foldersFlow .onEach { folders -> - _state.update { it.copy(folders = folders) } + _foldersState.update { it.copy(folders = folders) } } .launchIn(scope) chatFolderRepository.folderLoadingFlow .onEach { update -> - _state.update { + _foldersState.update { val newLoadingByFolder = it.isLoadingByFolder.toMutableMap() newLoadingByFolder[update.folderId] = update.isLoading it.copy(isLoadingByFolder = newLoadingByFolder) } + if (update.folderId == _foldersState.value.selectedFolderId) { + _chatsState.update { + it.copy(isLoading = update.isLoading) + } + } } .launchIn(scope) chatListRepository.connectionStateFlow .onEach { status -> - _state.update { it.copy(connectionStatus = status) } + _uiState.update { it.copy(connectionStatus = status) } } .launchIn(scope) appPreferences.enabledProxyId .onEach { enabledProxyId -> - _state.update { it.copy(isProxyEnabled = enabledProxyId != null) } + _uiState.update { it.copy(isProxyEnabled = enabledProxyId != null) } } .launchIn(scope) chatOperationsRepository.isArchivePinned .onEach { isPinned -> - _state.update { it.copy(isArchivePinned = isPinned) } + _uiState.update { it.copy(isArchivePinned = isPinned) } } .launchIn(scope) chatOperationsRepository.isArchiveAlwaysVisible .onEach { alwaysVisible -> - _state.update { it.copy(isArchiveAlwaysVisible = alwaysVisible) } + _uiState.update { it.copy(isArchiveAlwaysVisible = alwaysVisible) } } .launchIn(scope) chatSearchRepository.searchHistory .onEach { history -> - _state.update { it.copy(searchHistory = history) } + _searchState.update { + it.copy( + recentUsers = history.filter { chat -> + (chat.type == ChatType.PRIVATE || chat.type == ChatType.SECRET) && !chat.isBot + }, + recentOthers = history.filter { chat -> + chat.type != ChatType.PRIVATE && chat.type != ChatType.SECRET || chat.isBot + } + ) + } } .launchIn(scope) attachMenuBotRepository.getAttachMenuBots() .onEach { bots -> - _state.update { it.copy(attachMenuBots = bots) } + _uiState.update { it.copy(attachMenuBots = bots) } bots.firstOrNull()?.let { bot -> if (bot.botUserId != 0L) { val botInfo = botRepository.getBotInfo(bot.botUserId) val menuButton = botInfo?.menuButton if (menuButton is BotMenuButtonModel.WebApp) { - _state.update { + _uiState.update { it.copy( botWebAppUrl = menuButton.url, botWebAppName = menuButton.text @@ -172,7 +184,7 @@ class DefaultChatListComponent( updateRepository.updateState .onEach { updateState -> - _state.update { it.copy(updateState = updateState) } + _uiState.update { it.copy(updateState = updateState) } } .launchIn(scope) @@ -180,12 +192,8 @@ class DefaultChatListComponent( updateRepository.checkForUpdates() } - _state.onEach { - store.accept(ChatListStore.Intent.UpdateState(it)) - }.launchIn(scope) - scope.launch(Dispatchers.IO) { - chatListRepository.selectFolder(_state.value.selectedFolderId) + chatListRepository.selectFolder(_foldersState.value.selectedFolderId) } } @@ -194,9 +202,9 @@ class DefaultChatListComponent( } override fun onFolderClicked(id: Int) { - if (_state.value.selectedFolderId == id) return + if (_foldersState.value.selectedFolderId == id) return - _state.update { + _foldersState.update { val loadingByFolder = it.isLoadingByFolder.toMutableMap() loadingByFolder[id] = true it.copy( @@ -205,17 +213,22 @@ class DefaultChatListComponent( ) } + _chatsState.value = ChatListComponent.ChatsState( + chats = _foldersState.value.chatsByFolder[id].orEmpty(), + isLoading = true + ) + scope.launch(Dispatchers.IO) { chatListRepository.selectFolder(id) } } override fun loadMore(folderId: Int?) { - val targetFolderId = folderId ?: _state.value.selectedFolderId - if (_state.value.isLoadingByFolder[targetFolderId] == true) return + val targetFolderId = folderId ?: _foldersState.value.selectedFolderId + if (_foldersState.value.isLoadingByFolder[targetFolderId] == true) return scope.launch(Dispatchers.IO) { - if (folderId != null && folderId != _state.value.selectedFolderId) { + if (folderId != null && folderId != _foldersState.value.selectedFolderId) { return@launch } chatListRepository.loadNextChunk(20) @@ -226,11 +239,11 @@ class DefaultChatListComponent( if (isFetchingMoreMessages || nextMessagesOffset.isEmpty()) return isFetchingMoreMessages = true - val query = _state.value.searchQuery + val query = _searchState.value.searchQuery scope.launch(Dispatchers.IO) { val result = chatSearchRepository.searchMessages(query, offset = nextMessagesOffset) nextMessagesOffset = result.nextOffset - _state.update { + _searchState.update { it.copy( messageSearchResults = it.messageSearchResults + result.messages, canLoadMoreMessages = nextMessagesOffset.isNotEmpty() @@ -241,12 +254,12 @@ class DefaultChatListComponent( } override fun onChatClicked(id: Long) { - if (_state.value.isForwarding) { + if (_uiState.value.isForwarding) { toggleSelection(id) - } else if (_state.value.selectedChatIds.isNotEmpty()) { + } else if (_selectionState.value.selectedChatIds.isNotEmpty()) { toggleSelection(id) } else { - if (_state.value.isSearchActive) { + if (_searchState.value.isSearchActive) { chatSearchRepository.addSearchChatId(id) } onSelect(id, null) @@ -258,12 +271,12 @@ class DefaultChatListComponent( } override fun onMessageClicked(chatId: Long, messageId: Long) { - if (_state.value.isForwarding) { + if (_uiState.value.isForwarding) { toggleSelection(chatId) - } else if (_state.value.selectedChatIds.isNotEmpty()) { + } else if (_selectionState.value.selectedChatIds.isNotEmpty()) { toggleSelection(chatId) } else { - if (_state.value.isSearchActive) { + if (_searchState.value.isSearchActive) { chatSearchRepository.addSearchChatId(chatId) } onSelect(chatId, messageId) @@ -275,7 +288,7 @@ class DefaultChatListComponent( } override fun clearSelection() { - _state.update { it.copy(selectedChatIds = emptySet()) } + _selectionState.update { it.copy(selectedChatIds = emptySet()) } } override fun onSettingsClicked() { @@ -283,9 +296,10 @@ class DefaultChatListComponent( } override fun onSearchToggle() { - _state.update { + val isSearchActive = !_searchState.value.isSearchActive + _searchState.update { it.copy( - isSearchActive = !it.isSearchActive, + isSearchActive = isSearchActive, searchQuery = "", searchResults = emptyList(), globalSearchResults = emptyList(), @@ -297,22 +311,23 @@ class DefaultChatListComponent( } override fun onSearchQueryChange(query: String) { - _state.update { it.copy(searchQuery = query) } + _searchState.update { it.copy(searchQuery = query) } searchJob?.cancel() searchJob = scope.launch(Dispatchers.IO) { delay(300) if (query.isNotEmpty()) { - if (_state.value.selectedFolderId == -2) { - val archivedChats = _state.value.chatsByFolder[-2].orEmpty() + if (_foldersState.value.selectedFolderId == -2) { + val archivedChats = _foldersState.value.chatsByFolder[-2].orEmpty() val trimmedQuery = query.trim() val archiveResults = archivedChats.filter { chat -> chat.title.contains(trimmedQuery, ignoreCase = true) || chat.lastMessageText.contains(trimmedQuery, ignoreCase = true) } - _state.update { + _searchState.update { it.copy( + searchQuery = query, searchResults = archiveResults, globalSearchResults = emptyList(), messageSearchResults = emptyList(), @@ -324,14 +339,14 @@ class DefaultChatListComponent( } val localResults = chatSearchRepository.searchChats(query) - _state.update { it.copy(searchResults = localResults) } + _searchState.update { it.copy(searchResults = localResults) } val globalResults = chatSearchRepository.searchPublicChats(query) - _state.update { it.copy(globalSearchResults = globalResults) } + _searchState.update { it.copy(globalSearchResults = globalResults) } val messageResults = chatSearchRepository.searchMessages(query) nextMessagesOffset = messageResults.nextOffset - _state.update { + _searchState.update { it.copy( messageSearchResults = messageResults.messages, canLoadMoreMessages = nextMessagesOffset.isNotEmpty() @@ -339,8 +354,9 @@ class DefaultChatListComponent( } } else { nextMessagesOffset = "" - _state.update { + _searchState.update { it.copy( + searchQuery = "", searchResults = emptyList(), globalSearchResults = emptyList(), messageSearchResults = emptyList(), @@ -352,7 +368,7 @@ class DefaultChatListComponent( } override fun onSetEmojiStatus(customEmojiId: Long, statusPath: String?) { - _state.update { state -> + _uiState.update { state -> val user = state.currentUser ?: return@update state state.copy( currentUser = user.copy( @@ -378,8 +394,8 @@ class DefaultChatListComponent( } override fun onMuteSelected(mute: Boolean) { - val selectedIds = _state.value.selectedChatIds - val selectedChats = _state.value.chats.filter { selectedIds.contains(it.id) } + val selectedIds = _selectionState.value.selectedChatIds + val selectedChats = _chatsState.value.chats.filter { selectedIds.contains(it.id) } val shouldMute = selectedChats.any { !it.isMuted } scope.launch(Dispatchers.IO) { @@ -389,7 +405,7 @@ class DefaultChatListComponent( } override fun onArchiveSelected(archive: Boolean) { - val selectedIds = _state.value.selectedChatIds + val selectedIds = _selectionState.value.selectedChatIds scope.launch(Dispatchers.IO) { chatOperationsRepository.toggleArchiveChats(selectedIds, archive) clearSelection() @@ -397,10 +413,10 @@ class DefaultChatListComponent( } override fun onPinSelected() { - val selectedIds = _state.value.selectedChatIds - val selectedChats = _state.value.chats.filter { selectedIds.contains(it.id) } + val selectedIds = _selectionState.value.selectedChatIds + val selectedChats = _chatsState.value.chats.filter { selectedIds.contains(it.id) } val shouldPin = selectedChats.any { !it.isPinned } - val folderId = _state.value.selectedFolderId + val folderId = _foldersState.value.selectedFolderId scope.launch(Dispatchers.IO) { chatOperationsRepository.togglePinChats(selectedIds, shouldPin, folderId) @@ -409,8 +425,8 @@ class DefaultChatListComponent( } override fun onToggleReadSelected() { - val selectedIds = _state.value.selectedChatIds - val selectedChats = _state.value.chats.filter { selectedIds.contains(it.id) } + val selectedIds = _selectionState.value.selectedChatIds + val selectedChats = _chatsState.value.chats.filter { selectedIds.contains(it.id) } val shouldMarkUnread = selectedChats.any { !it.isMarkedAsUnread } scope.launch(Dispatchers.IO) { @@ -420,7 +436,7 @@ class DefaultChatListComponent( } override fun onDeleteSelected() { - val selectedIds = _state.value.selectedChatIds + val selectedIds = _selectionState.value.selectedChatIds scope.launch(Dispatchers.IO) { chatOperationsRepository.deleteChats(selectedIds) clearSelection() @@ -428,12 +444,13 @@ class DefaultChatListComponent( } override fun onArchivePinToggle() { - chatOperationsRepository.setArchivePinned(!_state.value.isArchivePinned) + chatOperationsRepository.setArchivePinned(!_uiState.value.isArchivePinned) } override fun onConfirmForwarding() { - if (_state.value.selectedChatIds.isNotEmpty()) { - onConfirmForward(_state.value.selectedChatIds) + val selectedChatIds = _selectionState.value.selectedChatIds + if (selectedChatIds.isNotEmpty()) { + onConfirmForward(selectedChatIds) } } @@ -454,7 +471,7 @@ class DefaultChatListComponent( scope.launch(Dispatchers.IO) { chatFolderRepository.deleteFolder(folderId) - if (_state.value.selectedFolderId == folderId) { + if (_foldersState.value.selectedFolderId == folderId) { onFolderClicked(-1) } } @@ -466,15 +483,15 @@ class DefaultChatListComponent( } override fun onOpenInstantView(url: String) { - _state.update { it.copy(instantViewUrl = url) } + _uiState.update { it.copy(instantViewUrl = url) } } override fun onDismissInstantView() { - _state.update { it.copy(instantViewUrl = null) } + _uiState.update { it.copy(instantViewUrl = null) } } override fun onOpenWebApp(url: String, botUserId: Long, botName: String) { - _state.update { + _uiState.update { it.copy( webAppUrl = url, webAppBotId = botUserId, @@ -484,7 +501,7 @@ class DefaultChatListComponent( } override fun onDismissWebApp() { - _state.update { + _uiState.update { it.copy( webAppUrl = null, webAppBotId = null, @@ -494,15 +511,15 @@ class DefaultChatListComponent( } override fun onOpenWebView(url: String) { - _state.update { it.copy(webViewUrl = url) } + _uiState.update { it.copy(webViewUrl = url) } } override fun onDismissWebView() { - _state.update { it.copy(webViewUrl = null) } + _uiState.update { it.copy(webViewUrl = null) } } override fun onUpdateClicked() { - val currentState = _state.value.updateState + val currentState = _uiState.value.updateState when (currentState) { is UpdateState.UpdateAvailable -> { updateRepository.downloadUpdate() @@ -524,32 +541,32 @@ class DefaultChatListComponent( override fun handleBack(): Boolean { return when { - state.value.webViewUrl != null -> { + uiState.value.webViewUrl != null -> { onDismissWebView() true } - state.value.webAppUrl != null -> { + uiState.value.webAppUrl != null -> { onDismissWebApp() true } - state.value.instantViewUrl != null -> { + uiState.value.instantViewUrl != null -> { onDismissInstantView() true } - state.value.isSearchActive -> { + searchState.value.isSearchActive -> { onSearchToggle() true } - state.value.selectedChatIds.isNotEmpty() -> { + selectionState.value.selectedChatIds.isNotEmpty() -> { clearSelection() true } - state.value.selectedFolderId == -2 -> { + foldersState.value.selectedFolderId == -2 -> { onFolderClicked(-1) true } - state.value.isForwarding -> { + uiState.value.isForwarding -> { onSelect(0L, null) true } @@ -558,7 +575,7 @@ class DefaultChatListComponent( } override fun updateScrollPosition(folderId: Int, index: Int, offset: Int) { - _state.update { + _foldersState.update { val newPositions = it.scrollPositions.toMutableMap() newPositions[folderId] = index to offset it.copy(scrollPositions = newPositions) @@ -570,13 +587,12 @@ class DefaultChatListComponent( } private fun toggleSelection(id: Long) { - _state.update { state -> - val newSelection = if (state.selectedChatIds.contains(id)) { - state.selectedChatIds - id - } else { - state.selectedChatIds + id - } - state.copy(selectedChatIds = newSelection) + val currentSelection = _selectionState.value.selectedChatIds + val newSelection = if (currentSelection.contains(id)) { + currentSelection - id + } else { + currentSelection + id } + _selectionState.value = _selectionState.value.copy(selectedChatIds = newSelection) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt index 3e9c0d24..8d5d5383 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt @@ -343,9 +343,9 @@ private fun ChatListItemContent( } else { emptyMap() } + val spoilerLabel = stringResource(R.string.message_spoiler) val annotatedDraft = if (draftHasSpoiler) { buildAnnotatedString { - val spoilerLabel = stringResource(R.string.message_spoiler) append(spoilerLabel) addStyle( SpanStyle( @@ -382,11 +382,11 @@ private fun ChatListItemContent( entities = chat.lastMessageEntities, fontSize = fontSize ) + val spoilerLabel = stringResource(R.string.message_spoiler) val annotatedText = if (chat.lastMessageText.isNotEmpty()) { val hasSpoiler = chat.lastMessageEntities.any { it.type is MessageEntityType.Spoiler } if (hasSpoiler) { buildAnnotatedString { - val spoilerLabel = stringResource(R.string.message_spoiler) append(spoilerLabel) addStyle( SpanStyle( @@ -505,4 +505,4 @@ private fun ChatListItemStatus(chat: ChatModel) { ) } } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt index 21e95bf8..cec5e0f9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt @@ -84,6 +84,7 @@ interface ChatComponent { fun onVideoRecorded(file: File) fun onForwardMessage(message: MessageModel) fun onForwardSelectedMessages() + fun onRepeatMessage(message: MessageModel) fun onDeleteMessage(message: MessageModel, revoke: Boolean = false) fun onEditMessage(message: MessageModel) fun onCancelEdit() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt index d4ad132a..91760061 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt @@ -66,6 +66,7 @@ interface ChatStore : Store component.handleRepeatMessage(intent.message) is Intent.DeleteMessage -> component.handleDeleteMessage(intent.message, intent.revoke) is Intent.EditMessage -> component._state.update { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt index de33f752..88174293 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt @@ -376,6 +376,8 @@ class DefaultChatComponent( override fun onForwardSelectedMessages() = store.accept(ChatStore.Intent.ForwardSelectedMessages) + override fun onRepeatMessage(message: MessageModel) = store.accept(ChatStore.Intent.RepeatMessage(message)) + override fun onDeleteMessage(message: MessageModel, revoke: Boolean) = store.accept(ChatStore.Intent.DeleteMessage(message, revoke)) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt index 9b63400b..a19d3279 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt @@ -423,7 +423,11 @@ fun ChatMessageOptionsMenu( } onDismiss() }, - onDismiss = onDismiss + onRepeat = { + component.onRepeatMessage(selectedMessage) + onDismiss() + }, + onDismiss = onDismiss, ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt index 49085ce5..adf33516 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt @@ -341,3 +341,9 @@ internal fun DefaultChatComponent.handleCopyLink(localClipboard: Clipboard) { } } } + +internal fun DefaultChatComponent.handleRepeatMessage(message: MessageModel) { + scope.launch { + repositoryMessage.forwardMessage(chatId, chatId, message.id) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt index 574d9e4d..851278be 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt @@ -67,6 +67,7 @@ import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Gavel import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.MoreHoriz +import androidx.compose.material.icons.rounded.PlusOne import androidx.compose.material.icons.rounded.PushPin import androidx.compose.material.icons.rounded.Report import androidx.compose.material.icons.rounded.Translate @@ -188,7 +189,8 @@ fun MessageOptionsMenu( onReport: () -> Unit = {}, onBlock: () -> Unit = {}, onRestrict: () -> Unit = {}, - onDismiss: () -> Unit + onRepeat: () -> Unit, + onDismiss: () -> Unit, ) { val density = LocalDensity.current val haptic = LocalHapticFeedback.current @@ -759,6 +761,14 @@ fun MessageOptionsMenu( ) } + if (sections.hasRepeatAction) { + InternalMenuOptionItem( + icon = Icons.Rounded.PlusOne, + text = stringResource(R.string.menu_repeat), + onClick = { animateOutAndDismiss(onRepeat) } + ) + } + if (sections.hasDownloadAction) { InternalMenuOptionItem( icon = Icons.Rounded.Download, @@ -927,7 +937,8 @@ private data class MessageMenuSections( val hasRestrictAction: Boolean, val hasTelegramSummaryAction: Boolean, val hasTelegramTranslatorAction: Boolean, - val hasRestoreOriginalTextAction: Boolean + val hasRestoreOriginalTextAction: Boolean, + val hasRepeatAction: Boolean, ) { fun merge(other: MessageMenuSections): MessageMenuSections { return MessageMenuSections( @@ -948,7 +959,8 @@ private data class MessageMenuSections( hasRestrictAction = hasRestrictAction || other.hasRestrictAction, hasTelegramSummaryAction = hasTelegramSummaryAction || other.hasTelegramSummaryAction, hasTelegramTranslatorAction = hasTelegramTranslatorAction || other.hasTelegramTranslatorAction, - hasRestoreOriginalTextAction = hasRestoreOriginalTextAction || other.hasRestoreOriginalTextAction + hasRestoreOriginalTextAction = hasRestoreOriginalTextAction || other.hasRestoreOriginalTextAction, + hasRepeatAction = hasRepeatAction || other.hasRepeatAction, ) } @@ -973,7 +985,8 @@ private data class MessageMenuSections( it.hasRestrictAction, it.hasTelegramSummaryAction, it.hasTelegramTranslatorAction, - it.hasRestoreOriginalTextAction + it.hasRestoreOriginalTextAction, + it.hasRepeatAction, ) }, restore = { values -> @@ -995,7 +1008,8 @@ private data class MessageMenuSections( hasRestrictAction = values[14], hasTelegramSummaryAction = values[15], hasTelegramTranslatorAction = values[16], - hasRestoreOriginalTextAction = values[17] + hasRestoreOriginalTextAction = values[17], + hasRepeatAction = values[18], ) } ) @@ -1039,7 +1053,8 @@ private fun buildMenuSections( hasRestrictAction = canBlock && canRestrict, hasTelegramSummaryAction = showTelegramSummary, hasTelegramTranslatorAction = showTelegramTranslator, - hasRestoreOriginalTextAction = showRestoreOriginalText + hasRestoreOriginalTextAction = showRestoreOriginalText, + hasRepeatAction = message.canBeForwarded && canWrite, ) } diff --git a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt index 27d1790d..9af327cc 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -4,12 +4,27 @@ package org.monogram.presentation.root import android.os.Parcelable import android.util.Log import com.arkivanov.decompose.DelicateDecomposeApi -import com.arkivanov.decompose.router.stack.* +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.bringToFront +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.navigate +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.popWhile +import com.arkivanov.decompose.router.stack.push +import com.arkivanov.decompose.router.stack.replaceAll import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize @@ -17,7 +32,19 @@ import kotlinx.serialization.Serializable import org.monogram.domain.managers.PhoneManager import org.monogram.domain.models.MessageContent import org.monogram.domain.models.ProxyTypeModel -import org.monogram.domain.repository.* +import org.monogram.domain.repository.AuthRepository +import org.monogram.domain.repository.AuthStep +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.ExternalNavigator +import org.monogram.domain.repository.ExternalProxyRepository +import org.monogram.domain.repository.LinkAction +import org.monogram.domain.repository.LinkHandlerRepository +import org.monogram.domain.repository.MessageDisplayer +import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.StorageRepository +import org.monogram.domain.repository.UpdateRepository +import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.coRunCatching @@ -28,7 +55,11 @@ import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent import org.monogram.presentation.features.chats.currentChat.components.VideoPlayerPool import org.monogram.presentation.features.chats.newChat.DefaultNewChatComponent import org.monogram.presentation.features.profile.DefaultProfileComponent -import org.monogram.presentation.features.profile.admin.* +import org.monogram.presentation.features.profile.admin.DefaultAdminManageComponent +import org.monogram.presentation.features.profile.admin.DefaultChatEditComponent +import org.monogram.presentation.features.profile.admin.DefaultChatPermissionsComponent +import org.monogram.presentation.features.profile.admin.DefaultMemberListComponent +import org.monogram.presentation.features.profile.admin.MemberListComponent import org.monogram.presentation.features.profile.logs.DefaultProfileLogsComponent import org.monogram.presentation.features.stickers.core.toUi import org.monogram.presentation.features.webview.DefaultWebViewComponent @@ -299,9 +330,13 @@ class DefaultRootComponent( override fun confirmProxy(server: String, port: Int, type: ProxyTypeModel) { scope.launch { - externalProxyRepository.addProxy(server, port, true, type) + val proxy = externalProxyRepository.addProxy(server, port, true, type) dismissProxyConfirm() - messageDisplayer.show("Proxy added and enabled") + if (proxy != null) { + messageDisplayer.show("Proxy added and enabled") + } else { + messageDisplayer.show("Failed to add proxy") + } } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt index 85238d0c..7d5c0433 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt @@ -1,12 +1,37 @@ package org.monogram.presentation.settings.debug +import com.arkivanov.decompose.value.Value +import org.monogram.domain.repository.PushProvider +import org.monogram.domain.repository.UnifiedPushDebugStatus + interface DebugComponent { + val state: Value + fun onBackClicked() fun onCrashClicked() fun onShowSponsorSheetClicked() fun onForceSponsorSyncClicked() + fun onTestPushClicked() fun onDropDatabasesClicked() fun onDropCachePrefsClicked() fun onDropPrefsClicked() fun onDropDatabaseCacheClicked() -} \ No newline at end of file + + data class State( + val pushProvider: PushProvider = PushProvider.FCM, + val backgroundServiceEnabled: Boolean = true, + val hideForegroundNotification: Boolean = false, + val isPowerSavingMode: Boolean = false, + val isWakeLockEnabled: Boolean = true, + val batteryOptimizationEnabled: Boolean = false, + val isTdNotificationServiceRunning: Boolean = false, + val unifiedPushStatus: UnifiedPushDebugStatus = UnifiedPushDebugStatus.IDLE, + val unifiedPushEndpoint: String? = null, + val unifiedPushSavedDistributor: String? = null, + val unifiedPushAckDistributor: String? = null, + val unifiedPushDistributorsCount: Int = 0, + val isGmsAvailable: Boolean = false, + val isFcmAvailable: Boolean = false, + val isUnifiedPushDistributorAvailable: Boolean = false + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt index 7384ee7f..b7cd2f97 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt @@ -1,26 +1,74 @@ package org.monogram.presentation.settings.debug -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.BatteryAlert +import androidx.compose.material.icons.rounded.BatterySaver +import androidx.compose.material.icons.rounded.Bookmark +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Cloud +import androidx.compose.material.icons.rounded.CloudDone +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteSweep +import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.Hub +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.NotificationAdd +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Power +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Storage +import androidx.compose.material.icons.rounded.Sync +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import org.monogram.domain.repository.PushProvider +import org.monogram.domain.repository.UnifiedPushDebugStatus import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SectionHeader import org.monogram.presentation.core.ui.SettingsItem @OptIn(ExperimentalMaterial3Api::class) @Composable fun DebugContent(component: DebugComponent) { + val state by component.state.subscribeAsState() var isSponsorSheetVisible by remember { mutableStateOf(false) } if (isSponsorSheetVisible) { @@ -85,10 +133,20 @@ fun DebugContent(component: DebugComponent) { Scaffold( topBar = { TopAppBar( - title = { Text("Debug", fontWeight = FontWeight.Bold) }, + title = { + Text( + text = stringResource(R.string.debug_title), + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + }, navigationIcon = { IconButton(onClick = component::onBackClicked) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.cd_back) + ) } } ) @@ -98,9 +156,162 @@ fun DebugContent(component: DebugComponent) { modifier = Modifier .fillMaxSize() .padding(padding), - contentPadding = PaddingValues(16.dp) + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { item { + SectionHeader(stringResource(R.string.debug_section_push_diagnostics)) + SettingsItem( + icon = Icons.Rounded.Notifications, + title = "Push provider", + subtitle = when (state.pushProvider) { + PushProvider.FCM -> "FCM" + PushProvider.UNIFIED_PUSH -> "UnifiedPush" + PushProvider.GMS_LESS -> "GMS-less" + }, + iconBackgroundColor = Color(0xFF4CAF50), + position = ItemPosition.TOP, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.Sync, + title = "Push service running", + subtitle = state.isTdNotificationServiceRunning.toUiToggle(), + iconBackgroundColor = Color(0xFF00ACC1), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.NotificationAdd, + title = "Test Push", + subtitle = "Show local notification and trigger sync", + iconBackgroundColor = Color(0xFF009688), + position = ItemPosition.BOTTOM, + onClick = component::onTestPushClicked + ) + } + + item { + SectionHeader(stringResource(R.string.debug_section_runtime_flags)) + SettingsItem( + icon = Icons.Rounded.Settings, + title = "Keep-alive enabled", + subtitle = state.backgroundServiceEnabled.toUiToggle(), + iconBackgroundColor = Color(0xFF607D8B), + position = ItemPosition.TOP, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.BatteryAlert, + title = "Power saving mode", + subtitle = state.isPowerSavingMode.toUiToggle(), + iconBackgroundColor = Color(0xFFFF9800), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.Power, + title = "Wake lock", + subtitle = state.isWakeLockEnabled.toUiToggle(), + iconBackgroundColor = Color(0xFF3F51B5), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.BatterySaver, + title = "Battery optimization", + subtitle = state.batteryOptimizationEnabled.toUiToggle(), + iconBackgroundColor = Color(0xFF8BC34A), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.VisibilityOff, + title = "Hide foreground notification", + subtitle = state.hideForegroundNotification.toUiToggle(), + iconBackgroundColor = Color(0xFF9E9E9E), + position = ItemPosition.BOTTOM, + onClick = { } + ) + } + + item { + SectionHeader(stringResource(R.string.debug_section_push_environment)) + SettingsItem( + icon = Icons.Rounded.CloudDone, + title = "GMS available", + subtitle = state.isGmsAvailable.toUiToggle(), + iconBackgroundColor = Color(0xFF4285F4), + position = ItemPosition.TOP, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.Cloud, + title = "FCM available", + subtitle = state.isFcmAvailable.toUiToggle(), + iconBackgroundColor = Color(0xFF1E88E5), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.Hub, + title = "UnifiedPush distributor", + subtitle = state.isUnifiedPushDistributorAvailable.toUiToggle(), + iconBackgroundColor = Color(0xFF7E57C2), + position = ItemPosition.BOTTOM, + onClick = { } + ) + } + + item { + SectionHeader(stringResource(R.string.debug_section_unifiedpush_details)) + SettingsItem( + icon = Icons.Rounded.Info, + title = "UnifiedPush status", + subtitle = state.unifiedPushStatus.toUiText(), + iconBackgroundColor = Color(0xFF3949AB), + position = ItemPosition.TOP, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.Link, + title = "UnifiedPush endpoint", + subtitle = state.unifiedPushEndpoint?.takeIf { it.isNotBlank() } + ?: "Not registered", + iconBackgroundColor = Color(0xFF5C6BC0), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.Bookmark, + title = "Saved distributor", + subtitle = state.unifiedPushSavedDistributor?.takeIf { it.isNotBlank() } + ?: "None", + iconBackgroundColor = Color(0xFF6A1B9A), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.DoneAll, + title = "Ack distributor", + subtitle = state.unifiedPushAckDistributor?.takeIf { it.isNotBlank() } + ?: "None", + iconBackgroundColor = Color(0xFF512DA8), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.FilterList, + title = "Distributors count", + subtitle = state.unifiedPushDistributorsCount.toString(), + iconBackgroundColor = Color(0xFF9575CD), + position = ItemPosition.BOTTOM, + onClick = { } + ) + } + + item { + SectionHeader(stringResource(R.string.debug_section_sponsor)) SettingsItem( icon = Icons.Rounded.Favorite, title = stringResource(R.string.debug_sponsor_sheet_title), @@ -109,28 +320,27 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.TOP, onClick = { isSponsorSheetVisible = true } ) - } - item { SettingsItem( icon = Icons.Rounded.Sync, title = stringResource(R.string.debug_force_sponsor_sync_title), subtitle = stringResource(R.string.debug_force_sponsor_sync_subtitle), iconBackgroundColor = Color(0xFF00ACC1), - position = ItemPosition.MIDDLE, + position = ItemPosition.BOTTOM, onClick = component::onForceSponsorSyncClicked ) } + item { + SectionHeader(stringResource(R.string.debug_section_danger_zone)) SettingsItem( icon = Icons.Rounded.BugReport, title = "Crash App", subtitle = "Trigger a manual RuntimeException", iconBackgroundColor = Color.Red, - position = ItemPosition.MIDDLE, + position = ItemPosition.TOP, onClick = component::onCrashClicked ) - } - item { + SettingsItem( icon = Icons.Rounded.Storage, title = "Drop Databases", @@ -139,8 +349,7 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.MIDDLE, onClick = component::onDropDatabasesClicked ) - } - item { + SettingsItem( icon = Icons.Rounded.Storage, title = "Drop Cache Database", @@ -149,8 +358,7 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.MIDDLE, onClick = component::onDropDatabaseCacheClicked ) - } - item { + SettingsItem( icon = Icons.Rounded.DeleteSweep, title = "Drop Cache", @@ -159,8 +367,7 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.MIDDLE, onClick = component::onDropCachePrefsClicked ) - } - item { + SettingsItem( icon = Icons.Rounded.Delete, title = "Drop Prefs", @@ -169,7 +376,24 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.BOTTOM, onClick = component::onDropPrefsClicked ) + + Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) } } } } + +@Composable +private fun Boolean.toUiToggle(): String = if (this) { + stringResource(R.string.on_label) +} else { + stringResource(R.string.off_label) +} + +private fun UnifiedPushDebugStatus.toUiText(): String = when (this) { + UnifiedPushDebugStatus.IDLE -> "Idle" + UnifiedPushDebugStatus.REGISTERING -> "Registering" + UnifiedPushDebugStatus.REGISTERED -> "Registered" + UnifiedPushDebugStatus.FAILED -> "Failed" + UnifiedPushDebugStatus.UNREGISTERED -> "Unregistered" +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt index 484ca8e5..74e182f8 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt @@ -1,5 +1,11 @@ package org.monogram.presentation.settings.debug +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.update +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext import java.io.File @@ -11,7 +17,40 @@ class DefaultDebugComponent( private val messageDisplayer = container.utils.messageDisplayer() private val assetsManager = container.utils.assetsManager() private val externalNavigator = container.utils.externalNavigator() + private val distrManager = container.utils.distrManager() + private val pushDebugRepository = container.repositories.pushDebugRepository private val sponsorRepository = container.repositories.sponsorRepository + private val scope = componentScope + + private val _state = MutableValue( + DebugComponent.State( + isGmsAvailable = distrManager.isGmsAvailable(), + isFcmAvailable = distrManager.isFcmAvailable(), + isUnifiedPushDistributorAvailable = distrManager.isUnifiedPushDistributorAvailable() + ) + ) + override val state: Value = _state + + init { + pushDebugRepository.diagnostics.onEach { diagnostics -> + _state.update { + it.copy( + pushProvider = diagnostics.pushProvider, + backgroundServiceEnabled = diagnostics.backgroundServiceEnabled, + hideForegroundNotification = diagnostics.hideForegroundNotification, + isPowerSavingMode = diagnostics.isPowerSavingMode, + isWakeLockEnabled = diagnostics.isWakeLockEnabled, + batteryOptimizationEnabled = diagnostics.batteryOptimizationEnabled, + isTdNotificationServiceRunning = diagnostics.isTdNotificationServiceRunning, + unifiedPushStatus = diagnostics.unifiedPushStatus, + unifiedPushEndpoint = diagnostics.unifiedPushEndpoint, + unifiedPushSavedDistributor = diagnostics.unifiedPushSavedDistributor, + unifiedPushAckDistributor = diagnostics.unifiedPushAckDistributor, + unifiedPushDistributorsCount = diagnostics.unifiedPushDistributorsCount + ) + } + }.launchIn(scope) + } override fun onBackClicked() { onBack() @@ -30,6 +69,11 @@ class DefaultDebugComponent( messageDisplayer.show("Sponsor sync started") } + override fun onTestPushClicked() { + pushDebugRepository.triggerTestPush() + messageDisplayer.show("Debug push dispatched") + } + override fun onDropDatabasesClicked() { messageDisplayer.show("Dropping databases and restarting...") assetsManager.getDatabasePath("monogram_db").delete() diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt index 075ba5bf..126d03cf 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt @@ -1,7 +1,11 @@ package org.monogram.presentation.settings.notifications import android.os.Parcelable -import com.arkivanov.decompose.router.stack.* +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.push import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update @@ -61,6 +65,7 @@ interface NotificationsComponent { val showSenderOnly: Boolean = false, val pushProvider: PushProvider = PushProvider.FCM, val isGmsAvailable: Boolean = false, + val isUnifiedPushAvailable: Boolean = false, val privateExceptions: List? = null, val groupExceptions: List? = null, val channelExceptions: List? = null @@ -163,7 +168,12 @@ class DefaultNotificationsComponent( _state.update { it.copy(pushProvider = value) } }.launchIn(scope) - _state.update { it.copy(isGmsAvailable = distrManager.isGmsAvailable()) } + _state.update { + it.copy( + isGmsAvailable = distrManager.isGmsAvailable() && distrManager.isFcmAvailable(), + isUnifiedPushAvailable = distrManager.isUnifiedPushDistributorAvailable() + ) + } syncSettings() } @@ -296,7 +306,12 @@ class DefaultNotificationsComponent( onPriorityChanged(1) onRepeatNotificationsChanged(0) onShowSenderOnlyToggled(false) - onPushProviderChanged(if (_state.value.isGmsAvailable) PushProvider.FCM else PushProvider.GMS_LESS) + val defaultProvider = when { + _state.value.isGmsAvailable -> PushProvider.FCM + _state.value.isUnifiedPushAvailable -> PushProvider.UNIFIED_PUSH + else -> PushProvider.GMS_LESS + } + onPushProviderChanged(defaultProvider) } override fun onExceptionClicked(scope: TdNotificationScope) { diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt index 15ab5036..0eef28c1 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt @@ -1,19 +1,59 @@ package org.monogram.presentation.settings.notifications +import android.widget.Toast import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.VolumeUp -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.Campaign +import androidx.compose.material.icons.rounded.ChatBubbleOutline +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.NotificationsActive +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PersonAdd +import androidx.compose.material.icons.rounded.PriorityHigh +import androidx.compose.material.icons.rounded.PushPin +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.Sync +import androidx.compose.material.icons.rounded.Vibration +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -29,6 +69,8 @@ import org.monogram.presentation.core.ui.ExpressiveDefaults import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsItem import org.monogram.presentation.core.ui.SettingsSwitchTile +import org.monogram.presentation.core.util.findActivity +import org.unifiedpush.android.connector.UnifiedPush @Composable fun NotificationsContent(component: NotificationsComponent) { @@ -44,6 +86,7 @@ fun NotificationsContent(component: NotificationsComponent) { @Composable private fun NotificationsMainContent(component: NotificationsComponent) { val state by component.state.subscribeAsState() + val context = LocalContext.current var showVibrationSheet by remember { mutableStateOf(false) } var showPrioritySheet by remember { mutableStateOf(false) } var showRepeatSheet by remember { mutableStateOf(false) } @@ -196,6 +239,7 @@ private fun NotificationsMainContent(component: NotificationsComponent) { title = stringResource(R.string.push_provider_title), subtitle = when (state.pushProvider) { PushProvider.FCM -> stringResource(R.string.push_provider_fcm) + PushProvider.UNIFIED_PUSH -> stringResource(R.string.push_provider_unified) PushProvider.GMS_LESS -> stringResource(R.string.push_provider_gms_less) }, iconBackgroundColor = Color(0xFF4CAF50), @@ -209,6 +253,7 @@ private fun NotificationsMainContent(component: NotificationsComponent) { checked = state.backgroundServiceEnabled, iconColor = Color(0xFF607D8B), position = ItemPosition.MIDDLE, + enabled = state.pushProvider == PushProvider.GMS_LESS, onCheckedChange = component::onBackgroundServiceToggled ) SettingsSwitchTile( @@ -218,6 +263,7 @@ private fun NotificationsMainContent(component: NotificationsComponent) { checked = state.hideForegroundNotification, iconColor = Color(0xFF9E9E9E), position = ItemPosition.BOTTOM, + enabled = state.pushProvider == PushProvider.GMS_LESS, onCheckedChange = component::onHideForegroundNotificationToggled ) } @@ -349,6 +395,9 @@ private fun NotificationsMainContent(component: NotificationsComponent) { if (state.isGmsAvailable) { options.add(PushProvider.FCM.name to stringResource(R.string.push_provider_fcm)) } + if (state.isUnifiedPushAvailable) { + options.add(PushProvider.UNIFIED_PUSH.name to stringResource(R.string.push_provider_unified)) + } options.add(PushProvider.GMS_LESS.name to stringResource(R.string.push_provider_gms_less)) NotificationOptionSheet( @@ -356,8 +405,35 @@ private fun NotificationsMainContent(component: NotificationsComponent) { options = options, selectedOption = state.pushProvider.name, onOptionSelected = { - component.onPushProviderChanged(PushProvider.valueOf(it)) - showPushProviderSheet = false + val selected = PushProvider.valueOf(it) + if (selected != PushProvider.UNIFIED_PUSH) { + component.onPushProviderChanged(selected) + showPushProviderSheet = false + return@NotificationOptionSheet + } + + val activity = context.findActivity() + if (activity == null) { + Toast.makeText( + context, + "Cannot select UnifiedPush without active activity", + Toast.LENGTH_SHORT + ).show() + return@NotificationOptionSheet + } + + UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success -> + if (success) { + component.onPushProviderChanged(PushProvider.UNIFIED_PUSH) + showPushProviderSheet = false + } else { + Toast.makeText( + context, + "UnifiedPush distributor not selected", + Toast.LENGTH_SHORT + ).show() + } + } }, onDismiss = { showPushProviderSheet = false } ) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt index 97e31afe..c0577d40 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONArray import org.json.JSONObject import org.monogram.domain.models.ProxyModel @@ -21,6 +20,7 @@ import org.monogram.domain.repository.ProxyNetworkMode import org.monogram.domain.repository.ProxyNetworkRule import org.monogram.domain.repository.ProxyNetworkType import org.monogram.domain.repository.ProxySortMode +import org.monogram.domain.repository.ProxyTestResult import org.monogram.domain.repository.ProxyUnavailableFallback import org.monogram.domain.repository.defaultProxyNetworkMode import org.monogram.presentation.core.util.componentScope @@ -79,6 +79,8 @@ interface ProxyComponent { val proxyToEdit: ProxyModel? = null, val proxyToDelete: ProxyModel? = null, val testPing: Long? = null, + val testError: String? = null, + val proxyErrors: Map = emptyMap(), val isTesting: Boolean = false, val toastMessage: String? = null, val showClearOfflineConfirmation: Boolean = false, @@ -98,6 +100,17 @@ class DefaultProxyComponent( override val state: Value = _state private val scope = componentScope private var restoreAttempted = false + private var lastToastMessage: String? = null + private var lastToastAtMs: Long = 0L + + private fun showToastThrottled(message: String, throttleMs: Long = 1500L) { + val now = System.currentTimeMillis() + val isDuplicateTooSoon = lastToastMessage == message && (now - lastToastAtMs) < throttleMs + if (isDuplicateTooSoon) return + lastToastMessage = message + lastToastAtMs = now + _state.update { it.copy(toastMessage = message) } + } init { scope.launch { @@ -177,9 +190,10 @@ class DefaultProxyComponent( private suspend fun refreshProxies(shouldPing: Boolean = false) { _state.update { it.copy(isLoading = true) } - restoreUserProxiesIfNeeded() - val allProxies = externalProxyRepository.getProxies() + val restoredProxies = restoreUserProxiesIfNeeded() + val allProxies = externalProxyRepository.getProxies().ifEmpty { restoredProxies } _state.update { + val availableIds = allProxies.mapTo(HashSet()) { proxy -> proxy.id } it.copy( proxies = allProxies, visibleProxies = buildVisibleProxies( @@ -188,6 +202,7 @@ class DefaultProxyComponent( it.hideOfflineProxies, it.favoriteProxyId ), + proxyErrors = it.proxyErrors.filterKeys { id -> id in availableIds }, isLoading = false ) } @@ -269,17 +284,50 @@ class DefaultProxyComponent( } } - private suspend fun restoreUserProxiesIfNeeded() { - if (restoreAttempted) return - restoreAttempted = true + private fun upsertProxyLocally( + proxy: ProxyModel, + replaceId: Int? = null, + closeEditor: Boolean = false + ) { + _state.update { current -> + val existingId = replaceId ?: proxy.id + val withoutOld = current.proxies.filterNot { it.id == existingId || it.id == proxy.id } + val updatedProxies = withoutOld + proxy + val updatedErrors = current.proxyErrors.toMutableMap().apply { + remove(existingId) + remove(proxy.id) + } + current.copy( + proxies = updatedProxies, + visibleProxies = buildVisibleProxies( + updatedProxies, + current.proxySortMode, + current.hideOfflineProxies, + current.favoriteProxyId + ), + proxyErrors = updatedErrors, + isAddingProxy = false, + proxyToEdit = if (closeEditor) null else current.proxyToEdit + ) + } + } + + private suspend fun restoreUserProxiesIfNeeded(): List { + if (restoreAttempted) return emptyList() val backups = appPreferences.userProxyBackups.value - if (backups.isEmpty()) return + if (backups.isEmpty()) { + restoreAttempted = true + return emptyList() + } val existing = externalProxyRepository.getProxies() - if (existing.isNotEmpty()) return + if (existing.isNotEmpty()) { + restoreAttempted = true + return emptyList() + } - backups.mapNotNull { parseProxyBackup(it) }.forEach { backup -> + val restored = backups.mapNotNull { parseProxyBackup(it) }.mapNotNull { backup -> externalProxyRepository.addProxy( server = backup.server, port = backup.port, @@ -287,6 +335,8 @@ class DefaultProxyComponent( type = backup.type ) } + restoreAttempted = true + return restored } private fun addProxyToBackup(proxy: ProxyModel) { @@ -428,11 +478,27 @@ class DefaultProxyComponent( override fun onBackClicked() = onBack() override fun onAddProxyClicked() { - _state.update { it.copy(isAddingProxy = true, proxyToEdit = null, testPing = null, isTesting = false) } + _state.update { + it.copy( + isAddingProxy = true, + proxyToEdit = null, + testPing = null, + testError = null, + isTesting = false + ) + } } override fun onEditProxyClicked(proxy: ProxyModel) { - _state.update { it.copy(proxyToEdit = proxy, isAddingProxy = false, testPing = null, isTesting = false) } + _state.update { + it.copy( + proxyToEdit = proxy, + isAddingProxy = false, + testPing = null, + testError = null, + isTesting = false + ) + } } override fun onProxyClicked(proxy: ProxyModel) { @@ -576,21 +642,31 @@ class DefaultProxyComponent( private suspend fun performPingAll() { val allProxies = _state.value.proxies - val pings = coroutineScope { + val pingResults = coroutineScope { allProxies.map { proxy -> proxy.id to async { - withTimeoutOrNull(5000) { - externalProxyRepository.pingProxy(proxy.id) - } ?: -1L + externalProxyRepository.pingProxyDetailed(proxy.id) } }.associate { (id, job) -> id to job.await() } } val updatedProxies = _state.value.proxies.map { proxy -> - pings[proxy.id]?.let { proxy.copy(ping = it) } ?: proxy + when (val result = pingResults[proxy.id]) { + is ProxyTestResult.Success -> proxy.copy(ping = result.ping) + is ProxyTestResult.Failure -> proxy.copy(ping = -1L) + else -> proxy + } } _state.update { + val updatedErrors = it.proxyErrors.toMutableMap() + pingResults.forEach { (proxyId, result) -> + if (result is ProxyTestResult.Failure) { + updatedErrors[proxyId] = result.message + } else { + updatedErrors.remove(proxyId) + } + } it.copy( proxies = updatedProxies, visibleProxies = buildVisibleProxies( @@ -598,22 +674,26 @@ class DefaultProxyComponent( it.proxySortMode, it.hideOfflineProxies, it.favoriteProxyId - ) + ), + proxyErrors = updatedErrors ) } } override fun onPingProxy(proxyId: Int) { scope.launch { - val ping = withTimeoutOrNull(5000) { - externalProxyRepository.pingProxy(proxyId) - } ?: -1L + val result = externalProxyRepository.pingProxyDetailed(proxyId) + val ping = if (result is ProxyTestResult.Success) result.ping else -1L + val errorMessage = (result as? ProxyTestResult.Failure)?.message val updatedProxies = _state.value.proxies.map { if (it.id == proxyId) it.copy(ping = ping) else it } _state.update { + val updatedErrors = it.proxyErrors.toMutableMap() + if (errorMessage != null) updatedErrors[proxyId] = + errorMessage else updatedErrors.remove(proxyId) it.copy( proxies = updatedProxies, visibleProxies = buildVisibleProxies( @@ -621,19 +701,37 @@ class DefaultProxyComponent( it.proxySortMode, it.hideOfflineProxies, it.favoriteProxyId - ) + ), + proxyErrors = updatedErrors ) } } } override fun onTestProxy(server: String, port: Int, type: ProxyTypeModel) { - _state.update { it.copy(isTesting = true, testPing = null) } + _state.update { it.copy(isTesting = true, testPing = null, testError = null) } scope.launch { - val ping = withTimeoutOrNull(10000) { - externalProxyRepository.testProxy(server, port, type) - } ?: -1L - _state.update { it.copy(isTesting = false, testPing = ping) } + when (val result = externalProxyRepository.testProxyDetailed(server, port, type)) { + is ProxyTestResult.Success -> { + _state.update { + it.copy( + isTesting = false, + testPing = result.ping, + testError = null + ) + } + } + + is ProxyTestResult.Failure -> { + _state.update { + it.copy( + isTesting = false, + testPing = -1L, + testError = result.message + ) + } + } + } } } @@ -645,9 +743,10 @@ class DefaultProxyComponent( ProxyNetworkType.entries.forEach { networkType -> appPreferences.setLastUsedProxyIdForNetwork(networkType, proxy.id) } - _state.update { it.copy(isAddingProxy = false) } - refreshProxies(shouldPing = false) + upsertProxyLocally(proxy) onPingProxy(proxy.id) + } else { + showToastThrottled("Failed to add proxy") } } } @@ -666,9 +765,10 @@ class DefaultProxyComponent( appPreferences.setLastUsedProxyIdForNetwork(networkType, proxy.id) } } - _state.update { it.copy(proxyToEdit = null) } - refreshProxies(shouldPing = false) + upsertProxyLocally(proxy, replaceId = proxyId, closeEditor = true) onPingProxy(proxy.id) + } else { + showToastThrottled("Failed to save proxy") } } } @@ -701,7 +801,15 @@ class DefaultProxyComponent( } override fun onDismissAddEdit() { - _state.update { it.copy(isAddingProxy = false, proxyToEdit = null, testPing = null, isTesting = false) } + _state.update { + it.copy( + isAddingProxy = false, + proxyToEdit = null, + testPing = null, + testError = null, + isTesting = false + ) + } } override fun onAutoBestProxyToggled(enabled: Boolean) { diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt index c4c80d95..bd6c53b0 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt @@ -129,6 +129,7 @@ import androidx.compose.ui.window.PopupProperties import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.proxy.MtprotoSecretNormalizer import org.monogram.domain.repository.ProxyNetworkMode import org.monogram.domain.repository.ProxyNetworkRule import org.monogram.domain.repository.ProxyNetworkType @@ -592,6 +593,7 @@ fun ProxyContent(component: ProxyComponent) { ) { ProxyItem( proxy = proxy, + errorMessage = state.proxyErrors[proxy.id], isFavorite = state.favoriteProxyId == proxy.id, position = position, onClick = { component.onProxyClicked(proxy) }, @@ -693,6 +695,7 @@ fun ProxyContent(component: ProxyComponent) { onDismiss = component::onDismissAddEdit, onTest = component::onTestProxy, testPing = state.testPing, + testError = state.testError, isTesting = state.isTesting, isFavorite = state.proxyToEdit?.id == state.favoriteProxyId, onToggleFavorite = { @@ -928,6 +931,7 @@ private fun proxyToDeepLink(proxy: ProxyModel): String { @Composable fun ProxyItem( proxy: ProxyModel, + errorMessage: String?, isFavorite: Boolean, position: ItemPosition, onClick: () -> Unit, @@ -1042,6 +1046,16 @@ fun ProxyItem( maxLines = 1 ) } + if (!errorMessage.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = errorMessage, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } Column(horizontalAlignment = Alignment.End) { @@ -1159,6 +1173,7 @@ fun ProxyAddEditSheet( onDismiss: () -> Unit, onTest: (String, Int, ProxyTypeModel) -> Unit, testPing: Long?, + testError: String?, isTesting: Boolean, isFavorite: Boolean, onToggleFavorite: () -> Unit, @@ -1201,15 +1216,18 @@ fun ProxyAddEditSheet( ) } - val currentProxyType = remember(type, secret, username, password) { + val normalizedMtprotoSecret = remember(secret) { MtprotoSecretNormalizer.normalize(secret) } + + val currentProxyType = remember(type, normalizedMtprotoSecret, secret, username, password) { when (type) { - "MTProto" -> ProxyTypeModel.Mtproto(secret) + "MTProto" -> ProxyTypeModel.Mtproto(normalizedMtprotoSecret ?: secret.trim()) "SOCKS5" -> ProxyTypeModel.Socks5(username, password) else -> ProxyTypeModel.Http(username, password, false) } } - val isInputValid = server.isNotBlank() && port.isNotBlank() && (type != "MTProto" || secret.isNotBlank()) + val isInputValid = + server.isNotBlank() && port.isNotBlank() && (type != "MTProto" || normalizedMtprotoSecret != null) ModalBottomSheet( onDismissRequest = onDismiss, @@ -1393,6 +1411,17 @@ fun ProxyAddEditSheet( } } + if (!testError.isNullOrBlank()) { + Text( + text = testError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) + } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index b711dec4..edcd602a 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -232,6 +232,7 @@ Fijar Desfijar Reenviar + +1 Seleccionar Más Eliminar @@ -723,7 +724,7 @@ - + Stickers y Emoji Stickers Emoji @@ -833,7 +834,7 @@ Buscar usuarios No se encontraron usuarios - + Cambiar Código de Acceso Establecer Código de Acceso Tu aplicación está protegida actualmente con un código de acceso. Ingresa uno nuevo para cambiarlo. @@ -949,7 +950,7 @@ Añadir foto Cambiar foto - + Permisos Requeridos Para proporcionar la mejor experiencia, MonoGram necesita los siguientes permisos. Notificaciones diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 6cb51871..e93956a9 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -223,6 +223,7 @@ Ամրացնել Ապաամրացնել Վերահասցեագրել + +1 Ընտրել Ավելին Ջնջել diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 07b62519..7090ef0e 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -233,6 +233,7 @@ Fixar Desafixar Encaminhar + +1 Selecionar Mais Excluir diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 34907cfc..b3771e5f 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -231,6 +231,7 @@ Закрепить Открепить Переслать + +1 Выбрать Ещё Удалить diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 6a4edc6a..0d8bd0bd 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -241,6 +241,7 @@ Pripnúť Odopnúť Preposlať + +1 Vybrať Viac Odstrániť diff --git a/presentation/src/main/res/values-tr/strings.xml b/presentation/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000..ad969e2b --- /dev/null +++ b/presentation/src/main/res/values-tr/strings.xml @@ -0,0 +1,2015 @@ + + +Onay + Bu cihazı yetkilendirmek istiyor musunuz? + Evet, giriş yap + İptal + + QR Tara + Cihazlar + Cihaz bağla + + Bu cihaz + Giriş istekleri + Aktif oturumlar + Giriş girişimi + Onaylanmamış + Oturumu sonlandır + + Geri git + Tarayıcıyı kapat + QR Tarayıcı simgesi + + + Telegram\'a bağlanılıyor… + + + Hakkında + Geri + MonoGram + Sürüm %1$s + Hizmet Şartları + Şartlar ve koşulları okuyun + Açık Kaynak Lisansları + MonoGram\'da kullanılan yazılımlar + GitHub + Kaynak kodunu görüntüle + TDLib Sürümü + %1$s (%2$s) + Topluluk + Telegram Sohbeti + Özellikleri tartışmak ve yardım almak için topluluğumuza katılın + Telegram Kanalı + En son haberler ve duyurulardan haberdar olun + MonoGram\'ı Destekle + Geliştirmeye destek olun ve projenin devam etmesini sağlayın + Geliştiriciler + Geliştirici + Simge ve Logo Tasarımcısı + MonoGram, Material Design 3 ile oluşturulmuş gayriresmi bir Telegram istemcisidir + © 2026 MonoGram + + + Güncellemeleri Denetle + Denetleniyor... + Güncelleme Mevcut: %1$s + Sürümünüz güncel + Güncelleme Hazır + Güncelleme Hatası + Yeni sürümü denetlemek için dokunun + Sunucuya bağlanılıyor + İndirilebilir yeni bir sürüm var + En son sürümü kullanıyorsunuz + Güncellemeyi yüklemek için dokunun + Güncelleme İndiriliyor... + %% %1$d + %1$s Yenilikleri + Güncellemeyi İndir + İptal + Yükleniyor... + + + Telefonunuz + Doğrulama + Şifre + Proxy Ayarları + Telefon Numaranız + Lütfen ülke kodunuzu onaylayın ve telefon numaranızı girin. + Ülke + Kod + Telefon Numarası + 000 00 00 + Devam Et + Ülke Seçin + Bilinmeyen ülke + Ülke veya kod ara... + Kodu diğer cihazınızdaki Telegram uygulamasına gönderdik. + Kodu SMS ile gönderdik. + Sizi kod için arıyoruz. + Kodu %1$s adresine gönderdik. + Doğrulama kodunu gönderdik. + Onayla + Kodu %1$s içinde tekrar gönder + SMS ile tekrar gönder + Arama ile tekrar gönder + Kodu tekrar gönder + Yanlış numara? + İki Adımlı Doğrulama + Hesabınız ek bir şifre ile korunuyor. + Şifre + Kilidi Aç + Yapıştır + Kimlik Doğrulama Hatası + Kapat + + + Lütfen bekleyin + Çok mu uzun sürdü? + Bağlantıyı Sıfırla + + + İlet... + + %1$d sohbet seçildi + %1$d sohbet seçildi + + Gönder + Arşivlenmiş Sohbetler + Yeni Sohbet + Son Aramalar + Tümünü Temizle + Sohbetler ve kişiler + Genel arama + Mesajlar + Daha fazla göster + Görüşme ara… + Ağ bekleniyor… + Bağlanıyor… + Güncelleniyor… + Proxy\'ye bağlanılıyor… + Proxy etkin + Proxy + Forum + Spoiler + Taslak: + Henüz gönderi yok + Henüz mesaj yok + Sabitlenmiş + Bahsetmeler + Ana listeden gizlenenler + Henüz sohbet yok + Yeni bir sohbet başlatın + Mini Uygulama + + + MonoGram Alpha + Hesap Ekle + Başka bir hesapla giriş yap + Profilim + Profilinizi görüntüleyin + Kayıtlı Mesajlar + Bulut depolama alanı + Ayarlar + Uygulama yapılandırması + Güncelleme Mevcut + Yeni sürüm %1$s mevcut + Güncelleme indiriliyor... %% %1$d + Güncelleme yüklenmeye hazır + Yardım ve Geri Bildirim + SSS ve destek + Gizlilik Politikası + Android için MonoGram Alpha v%1$s + Bilinmeyen Kullanıcı + Bilgi yok + Hesapları göster + + + Mesaj ara... + Temizle + Sessiz + Doğrulanmış + Sponsor + Sesi Aç + Sessize Al + Reklamları Filtrele + Kanalı Beyaz Listeye Al + Bağlantıyı Kopyala + Geçmişi Temizle + Sohbeti Sil + Rapor Et + KATIL + En alta kaydır + Bu konu kapatıldı + Ek Dosya + + + Mesaj silinsin mi? + %1$d mesaj silinsin mi? + Bu mesajı silmek istediğinizden emin misiniz? + Bu mesajları silmek istediğinizden emin misiniz? + Herkes için sil + Benim için sil + + Neden rapor ediyorsunuz? + Raporunuz anonimdir. Güvenliği sağlamak için sohbet geçmişini inceleyeceğiz. + Rapor detayları + Sorunu açıklayın… + Raporu Gönder + Spam + İstenmeyen ticari içerik veya dolandırıcılık + Şiddet + Şiddet tehdidi veya övülmesi + Pornografi + Uygunsuz medya veya müstehcen dil + Çocuk güvenliği + Reşit olmayanlara zarar veren içerik + Telif Hakkı + Başkasının fikri mülkiyetini kullanma + Taklit + Başka biri veya bir bot gibi davranma + Yasadışı uyuşturucular + Yasaklı maddelerin tanıtımı veya satışı + Gizlilik ihlali + Özel iletişim bilgileri veya adres paylaşımı + İlgisiz konum + Bu mekanla ilgili olmayan içerik + Diğer + Şartlarımızı ihlal eden başka bir durum + + Kullanıcıyı Kısıtla + Mesaj Gönder + Medya Gönder + Çıkartma ve GIF Gönder + Anket Gönder + Bağlantıları Önizle + Mesajları Sabitle + Sohbet Bilgisini Değiştir + Kısıtlama bitişi + Süresiz + Tarih Seç + Saat Seç + Kısıtla + + Yanıtla + Kopyala + Sabitle + Sabitlemeyi Kaldır + İlet + +1 + Seç + Daha Fazla + Sil + Yorumları Görüntüle + İndirilenlere Kaydet + Cocoon + Özet + Çevir + Telegram Cocoon ile oluşturuldu + Orijinal Metni Göster + Kullanıcıyı Kısıtla + Düzenlendi + Okundu + Görüntüleme + + + Bilinmiyor + + %1$d üye + %1$d üye + + %1$s, %2$d çevrimiçi + Henüz eklenmedi + Arama henüz eklenmedi + Paylaşım henüz eklenmedi + Engelleme henüz eklenmedi + Silme henüz eklenmedi + İstatistikler + Gelir + Paylaş + Düzenle + Kullanıcıyı Engelle + Ayrıl + + Mesaj + Katıl + Rapor Et + QR Kodu + Ekle + Kişisel Fotoğraf + Bu fotoğraf sadece sizin tarafınızdan görülebilir + Mini Uygulamayı Aç + Botun web uygulamasını başlat + Şartları Kabul Et + Botun hizmet şartlarını inceleyin ve kabul edin + Bot İzinleri + Bu bot için izinleri yönetin + Kullanıcı Adı + Bağlantı + Davet Bağlantısı + Bot Bilgisi + Açıklama + Hakkında + Doğum Tarihi + Konum + Çalışma Saatleri + Son Eylemler + Sohbet olay günlüğünü görüntüle + Detaylı sohbet istatistiklerini görüntüle + Sohbet gelir istatistiklerini görüntüle + Sizi rehberine ekledi + Sizi rehberine eklemedi + Rehberinizde kayıtlı + Rehberinizde kayıtlı değil + Profil hikayeleri + Profilinizden hikaye yayınlayabilirsiniz + Sohbet arka planı + Özel arka planlar ayarlayabilirsiniz + Sesli ve görüntülü mesajlar + Sesli ve görüntülü mesaj gönderme kısıtlandı + Yavaş mod + Üyeler her %1$s içinde bir mesaj gönderebilir + Korumalı içerik + İletme ve kaydetme kısıtlandı + Gizli iletmeler + İletilen mesajlar profil bağlantısını gizler + Sohbet İstatistikleri + %1$d yönetici + %1$d kısıtlı + %1$d yasaklı + Bilgi + Bildirimler + Mesajları otomatik sil + Ayarlar + Kaydet + Spam + Şiddet + Pornografi + Çocuk İstismarı + Telif Hakkı + Diğer + Kapat + Bu Mini Uygulamayı başlatarak Hizmet Şartlarını ve Gizlilik Politikasını kabul etmiş olursunuz. Bot, temel profil bilgilerinize erişebilecektir. + Kabul Et ve Başlat + + + QR Kodu + Paylaş + MonoGram ücretsiz ve açık kaynaklı bir projedir. Desteğiniz projenin devam etmesine ve yeni özellikler geliştirmemize yardımcı olur. + Kalp simgeli sponsor rozeti, Gelişmiş destek seviyesi veya 150 RUB (yaklaşık 1,96$) tutarındaki katkılar için geçerlidir. + Boosty üzerinden destekle + Belki daha sonra + Profili Düzenle + Maskelemek için basılı tutun + Görmek için basılı tutun, kopyalamak için tıklayın + ID Numaranız + Adınızı, biyografinizi ve profil fotoğrafınızı değiştirin + t.me bağlantılarını etkinleştir + Telegram bağlantılarını uygulama içinde aç + Genel + Sohbet Ayarları + Temalar, yazı boyutu, video oynatıcı + Gizlilik ve Güvenlik + Uygulama kilidi, aktif oturumlar, gizlilik + Bildirimler ve Sesler + Mesajlar, gruplar, aramalar + Veri ve Depolama + Ağ kullanımı, otomatik indirme + Güç Tasarrufu + Pil kullanım ayarları + Sohbet Klasörleri + Sohbetlerinizi düzenleyin + Çıkartmalar ve Emoji + Çıkartma ve emoji paketlerini yönetin + Bağlı cihazlar + Dil + İngilizce + Proxy Ayarları + MTProto, SOCKS5, HTTP + Telegram Premium + Özel özelliklerin kilidini açın + Projeyi geliştirmemize yardım edin + MonoGram Destekçisi + Bu kullanıcı projeyi destekliyor ve gelişmesine yardımcı oluyor + MonoGram sürümü ve bilgisi + Hata Ayıklama + Hata ayıklama seçenekleri + Sponsor sayfasını göster + Sponsor bilgi sayfasını aç + Sponsor senkronizasyonunu zorla + Sponsor ID\'lerini şimdi kanaldan çek + Push Tanılama + Çalışma Zamanı Bayrakları + Push Ortamı + UnifiedPush Ayrıntıları + Sponsor + Tehlike Bölgesi + Oturumu Kapat + Hesap bağlantısını kes + + + Kullanıcı Adları + %1$d sn sonra tekrar deneyin + Aktif Kullanıcı Adları + Devre Dışı Kullanıcı Adları + Koleksiyonluk Kullanıcı Adları + Tamam + Tamam + Başlangıç Saatini Seçin + Bitiş Saatini Seçin + Çalışma Saatleri + Çalışma Günleri + Zaman Aralığı + Başlangıç + Bitiş + İşletme Konumu + Adres + Konumu Onayla + Profili Düzenle + Ad (Zorunlu) + Soyad (İsteğe bağlı) + Biyografi + Yaş, meslek veya şehir gibi detaylar. Örnek: İstanbul\'dan 23 yaşında bir tasarımcı. + Kullanıcı Adı + Telegram\'da bir kullanıcı adı seçebilirsiniz. Seçerseniz, insanlar sizi bu kullanıcı adıyla bulabilir ve telefon numaranıza ihtiyaç duymadan sizinle iletişim kurabilirler. + Doğum Gününüz + Doğum Günü + Telegram İşletme + Bağlı Kanal ID\'si + İşletme Biyografisi + İşletme Adresi + Coğrafi Konum + Çalışma Saatleri + Premium kullanıcı olarak bir kanala bağlantı ekleyebilir ve profilinize işletme detaylarını set edebilirsiniz. + Ayarlanmadı + (%1$d gün) + Pzt + Sal + Çar + Per + Cum + Cmt + Paz + + + Gizlilik ve Güvenlik + Gizlilik + Engellenen Kullanıcılar + %1$d kullanıcı + Hiçbiri + Telefon Numarası + Son Görülme ve Çevrimiçi + Profil Fotoğrafları + İletilen Mesajlar + Aramalar + Gruplar ve Kanallar + Güvenlik + Uygulama Kilidi + Açık + Kapalı + Biyometrik Kilidi Aç + Kilidi açmak için parmak izi veya yüz tanıma kullanın + Aktif Oturumlar + Giriş yapılmış cihazlarınızı yönetin + Hassas İçerik + Filtrelemeyi devre dışı bırak + Tüm cihazlarınızdaki genel kanallarda hassas medyaları göster. + Gelişmiş + Hesabımı Sil + %1$s boyunca yoksam + Hesabı Şimdi Sil + Hesabınızı ve tüm verileri kalıcı olarak silin + Şu kadar süre aktif olmazsam sil... + Hesabı Sil + Hesabınızı silmek istediğinizden emin misiniz? Bu işlem kalıcıdır ve geri alınamaz. + Herkes + Kişilerim + Hiç kimse + 1 ay + 3 ay + 6 ay + 1 yıl + 1,5 yıl + 2 yıl + %1$d ay + %1$d gün + Kullanıcı silme isteği + + + Proxy Ayarları + Pingleri Yenile + Ekle + Bağlantı + Akıllı Geçiş + Otomatik olarak en hızlı proxy\'yi kullan + IPv6\'yı Tercih Et + Mümkün olduğunda IPv6 kullan + Proxy\'yi Devre Dışı Bırak + Doğrudan bağlı + Doğrudan bağlantıya geç + Ağ Kuralları + Her ağ türü için proxy çalışma şeklini seçin + Wi-Fi + Mobil veri + VPN + Diğer ağlar + Doğrudan + En iyi proxy + Son kullanılan + Belirli proxy + Bu ağda her zaman doğrudan bağlan + Bu ağdaki en hızlı proxy\'yi seç + Bu ağda kullanılan son proxy\'yi tekrar kullan + Bu ağda her zaman seçilen bir proxy\'yi kullan + Özel: %1$s:%2$d + Liste Davranışı + Proxy\'leri sırala + Proxy listesinin nasıl sıralanacağını seçin + Önce aktifler + En düşük ping + Sunucu adı + Proxy türü + Durum + Seçili proxy kullanılamıyorsa + Belirli/son kullanılan modlar için yedek davranış + En iyi proxy\'ye geç + Doğrudan bağlantıya geç + Mevcut durumu koru + Çevrimdışı proxy\'leri gizle + Sadece mevcut veya denetlenmemiş proxy\'leri göster + Proxy\'leri dışa aktar + Proxy\'leri içe aktar + Proxy listesi dışa aktarıldı + Proxy listesi dışa aktarılamadı + İçe aktarma dosyası okunamadı + Favorilere ekle + Favorilerden çıkar + Bağlantı olarak kopyala + Proxy\'yi düzenle + Proxy\'yi sil + Proxy bağlantısı kopyalandı + Listeyi Yenile + En son topluluk proxy\'lerini getir + Proxy\'leriniz + Çevrimdışıları Temizle + Tümünü Kaldır + Çevrimdışı Proxy\'leri Sil + Bu işlem şu an çevrimdışı olan tüm proxy\'leri kaldıracaktır. Devam edilsin mi? + Tüm Proxy\'leri Sil + Bu işlem uygulamadaki tüm yapılandırılmış proxy\'leri kaldıracaktır. Devam edilsin mi? + Proxy eklenmedi + Filtrelerle eşleşen proxy yok + Proxy\'yi Sil + %1$s proxy\'sini silmek istediğinizden emin misiniz? + Yeni Proxy + Proxy\'yi Düzenle + Sunucu Adresi + Port + Secret (Hex) + Kullanıcı Adı (İsteğe bağlı) + Şifre (İsteğe bağlı) + Kaydet + Test Et + Test Sonucu + Sil + Denetleniyor... + Çevrimdışı + %1$dms + + + Kullanıcı Adlarınız + Daha fazla seçenek + + + Bildirimler ve Sesler + Mesaj Bildirimleri + Özel Sohbetler + Gruplar + Kanallar + Bildirim Ayarları + Titreşim + Öncelik + Bildirimleri Tekrarla + Sadece Göndereni Göster + Bildirimlerde mesaj içeriğini gizle + Push Servisi + Push Sağlayıcı + Keep-Alive Servisi + Güvenilir bildirimler için uygulamayı arka planda çalışır durumda tut + Ön Plan Bildirimini Gizle + Servis başladıktan sonra bildirimi gizle. Sistemin servisi durdurmasına neden olabilir. + Uygulama İçi Bildirimler + Uygulama İçi Sesler + Uygulama İçi Titreşim + Uygulama İçi Önizleme + Etkinlikler + Kişi Telegram\'a Katıldı + Sabitlenmiş Mesajlar + Tüm Bildirimleri Sıfırla + Tüm kişiler ve gruplar için özel bildirim ayarlarını geri al + Titreşim Deseni + Bildirim Önceliği + %1$s, %2$d istisna + Varsayılan + Kısa + Uzun + Devre Dışı + Düşük + Varsayılan + Yüksek + Asla + Her %1$d dakikada bir + Her 1 saatte bir + Her %1$d saatte bir + Firebase Cloud Messaging + UnifiedPush (Simple Push) + GMS-less (Arka Plan Servisi) + + + Sohbet Ayarları + Görünüm + Mesaj metni boyutu + Mesaj harf aralığı + Balon yuvarlaklığı + Çıkartma boyutu + Sohbet Duvar Kağıdı + Duvar Kağıdını Sıfırla + Duvar Kağıdı Yükle + Emoji Stili + Tema + Gece Modu + Sistem + Açık + Koyu + Planlı + Otomatik + Mevcut davranış + Kullanılan: %1$s + Parlaklık eşiği: %1$d%% + Ekran parlaklığı bu seviyenin altına düştüğünde koyu temaya geç. + Dinamik Renkler + Dinamik Renkler + Uygulama teması için sistem renklerini kullan + Veri ve Depolama + Fotoğrafları Sıkıştır + Göndermeden önce fotoğraf boyutunu küçült + Videoları Sıkıştır + Göndermeden önce video boyutunu küçült + Video Oynatıcı + Hareketleri Etkinleştir + Ses ve parlaklığı kontrol etmek için kaydırın + Sarmak için Çift Dokun + İleri/geri sarmak için video kenarlarına çift dokunun + Sarma Süresi + Yakınlaştırmayı Etkinleştir + Video oynatıcıda parmakla yakınlaştırın + Sohbet Listesi + Arşivlenenleri Sabitle + Arşivlenmiş sohbetleri listenin başında tut + Sabit Arşivi Her Zaman Göster + Kaydırırken bile sabitlenmiş arşivi görünür tut + Bağlantı Önizlemelerini Göster + Mesajlardaki bağlantılar için önizleme göster + Geriye Sürükle + Geri gitmek için sol kenardan kaydırın + Tablet Arayüzü + Tabletlerde bölünmüş ekran düzenini kullan + İki satırlı + Üç satırlı + Fotoğrafları Göster + Sohbet listesinde profil fotoğraflarını göster + Deneysel + Kanallar için AdBlock + Kanallardaki sponsorlu gönderileri gizle + Son Medyalar + Son Çıkartmaları Temizle + Son kullanılan tüm çıkartmaları kaldır + Son Emojileri Temizle + Son kullanılan tüm emojileri kaldır + Emoji Paketini Kaldır + %1$s paketi kaldırılsın mı? + Bu işlem, indirilmiş emoji paketini cihazınızdan silecektir. Daha sonra tekrar indirebilirsiniz. + Özel temayı düzenle + Seçildi + Apple + Twitter + Windows + Catmoji + Noto + Sistem + + + Veri ve Depolama + Disk ve ağ kullanımı + Depolama Kullanımı + Yerel önbelleğinizi yönetin + Ağ Kullanımı + Gönderilen ve alınan verileri görüntüleyin + Otomatik medya indirme + Mobil veri kullanılırken + Wi-Fi bağlıyken + Dolaşımdayken + Dosyaları otomatik indir + Gelen dosyaları otomatik olarak indir + Çıkartmaları otomatik indir + Çıkartmaları otomatik olarak indir + Video mesajları otomatik indir + Video mesajları otomatik olarak indir + Medyayı otomatik oynat + GIF\'ler + Sohbet listesinde ve sohbetlerde GIF\'leri otomatik oynat + Videolar + Sohbetlerde videoları otomatik oynat + Etkin + Devre Dışı + + + Güç Tasarrufu + Pil + Güç Tasarrufu Modu + Pil tasarrufu için arka plan etkinliklerini ve animasyonları azaltır + Pil Kullanımını Optimize Et + Arka plan çalışmasını agresif bir şekilde kısıtla ve uyandırma kilitlerini serbest bırak + Uyandırma Kilidi (Wake Lock) + Arka plan görevleri için işlemciyi uyanık tut. Pil tasarrufu için devre dışı bırakın + Animasyonlar + Sohbet Animasyonları + Pil tasarrufu için sohbet animasyonlarını devre dışı bırak + Arka Plan + Bunu devre dışı bırakmak pil kullanımını azaltır ancak arka plan bildirimlerini geciktirebilir + + + Çıkartmalar ve Emoji + Çıkartmalar + Emoji + Son Çıkartmalar + Çıkartma Setleri + Arşivlenmiş Çıkartmalar + Kendi çıkartmalarını ekle + @Stickers botunu kullanarak kendi setlerini oluştur + Yüklü çıkartma seti yok + \"%1$s\" için çıkartma bulunamadı + Son Emojiler + Emoji Paketleri + Arşivlenmiş Emojiler + Kendi emojini ekle + @Stickers botunu kullanarak kendi paketlerini oluştur + Son Emojileri Temizle + Son kullanılan tüm emojileri kaldır + Yüklü emoji paketi yok + \"%1$s\" için emoji bulunamadı + Paket ara + Ara + + %1$d çıkartma + %1$d çıkartma + + + %1$d emoji + %1$d emoji + + Maskeler + Özel Emojiler + Resmî + Bağlantı panoya kopyalandı + + + Ağ Kullanımı + İstatistikleri Sıfırla + Ağ İstatistikleri + Ne kadar veri kullandığınızı takip edin. Devre dışı bırakmak disk alanı kullanımını azaltabilir. + Ağ İstatistikleri Devre Dışı + Ağ kullanımı takibi şu an kapalı. Mobil, Wi-Fi ve dolaşım ağlarında ne kadar veri kullandığınızı görmek için yukarıdaki anahtarı kullanarak etkinleştirin. + Toplam Kullanım + Gönderilen + Alınan + Genel Bakış + Uygulama Kullanımı + Kaydedilmiş kullanım verisi yok + İstatistik mevcut değil + Mobil + Wi-Fi + Dolaşım + Diğer + + + Depolama Kullanımı + Tüm Önbelleği Temizle • %1$s + Tüm sohbetlerdeki fotoğrafları, videoları, belgeleri, çıkartmaları ve GIF\'leri içerir. + Önbellek Sınırı + Önbelleği Otomatik Temizle + Depolama Optimize Edici + Arka planda depolama optimizasyonu + Detaylı Kullanım + Depolama Temiz + Önbelleğe alınmış dosya bulunamadı. + Önbelleği Temizle + \"%1$s\" için önbelleği temizlemek istediğinizden emin misiniz? Bu işlem %2$s alan boşaltacaktır. + Tüm Önbelleği Temizle + Tüm sohbetlerdeki önbelleğe alınmış medyalar silinecektir. Emin misiniz? + Sınırsız + Asla + Her Gün + Her Hafta + Her Ay + Toplam Kullanılan + %1$d dosya + %1$d dosya + + + Beni %1$s\'e kimler ekleyebilir? + Beni kimler arayabilir? + Son görülme zamanımı kimler görebilir? + Profil fotoğraflarımı kimler görebilir? + Biyografimi kimler görebilir? + Mesajlarım iletildiğinde profilime kimler bağlantı ekleyebilir? + Beni gruplara ve kanallara kimler ekleyebilir? + Telefon numaramı kimler görebilir? + Beni numaramla kimler bulabilir? + Numaranızı rehberine ekleyen kullanıcılar, ancak yukarıdaki ayar izin veriyorsa numaranızı görebilirler. + İstisnalar ekle + Her Zaman İzin Ver + Asla İzin Verme + %1$d kullanıcı/sohbet + (silindi) + Sohbet üyeleri + Telefon Numarası Araması + Engellenen kullanıcı yok + Engellenen kullanıcılar sizinle iletişime geçemez ve son görülme zamanınızı göremez. + Engeli Kaldır + Kullanıcıyı Engelle + Kullanıcı ara + Kullanıcı bulunamadı + + + Şifreyi Değiştir + Şifre Koy + Uygulamanız şu an bir şifre ile korunuyor. Değiştirmek için yenisini girin. + Uygulamayı kilitlemek ve gizliliğinizi korumak için 4 haneli bir şifre girin. + Şifre + Mevcut Şifre + Şifreyi Kaydet + Şifreyi Kapat + Şifreyi Doğrula + Değiştirmeden veya kapatmadan önce mevcut şifrenizi girin. + Hatalı şifre. + + + Anahtar Kelime Ekle + AdBlock\'u Etkinleştir + Beyaz Listedeki Kanallar + %1$d kanala izin verildi + Temel kelimeleri yükle + Varlıklardan yaygın reklam kelimelerini içe aktar + Tüm kelimeleri kopyala + Mevcut listeyi panoya kopyala + Tüm kelimeleri temizle + Listedeki tüm anahtar kelimeleri kaldır + Gönderileri gizlemek için anahtar kelimeler + Anahtar kelime eklenmedi + Filtreleme için kelime eklemek için + butonuna dokunun + Anahtar Kelime Ekle + Kanal gönderilerini filtrelemek için virgül veya yeni satır ile ayrılmış kelimeler girin. + örn: #reklam, ad, sponsorlu + Listeye Ekle + Bu kanallardaki gönderiler filtrelenmeyecektir + Beyaz listeye alınmış kanal yok + Kaldır + + + son görülme yakınlarda + son görülme az önce + + son görülme %1$d dakika önce + son görülme %1$d dakika önce + + son görülme %1$s + son görülme dün %1$s + son görülme %1$s + son görülme bir hafta içinde + son görülme bir ay içinde + son görülme çok uzun zaman önce + çevrimiçi + çevrimdışı + bot + Kullanıcı Adı + %1$d çıkartma + Arşivlendi + Ekle + Arşivden Çıkar + Arşivle + Seti kaldır + + + Yeni Mesaj + Üye Ekle + Yeni Grup + Yeni Kanal + %1$d kişi + %1$d / 200000 + %1$d seçildi + Kişilerde ara... + Kişi bulunamadı + \"%1$s\" için sonuç bulunamadı + Son görülme zamanına göre sıralandı + Yeni Grup + Yeni Kanal + Destek + Kanallar, mesajlarınızı sınırsız kitlelere yayınlamak için kullanılan bir araçtır. + Kanal Detayları + Kanal Adı + Açıklama (isteğe bağlı) + Mesajları Otomatik Sil + Kapalı + 1 gün + 2 gün + 3 gün + 4 gün + 5 gün + 6 gün + 1 hafta + 2 hafta + 3 hafta + 1 ay + 2 ay + 3 ay + 4 ay + 5 ay + 6 ay + 1 yıl + Kanalınız için isteğe bağlı bir açıklama ekleyebilirsiniz. Bağlantıya sahip olan herkes kanalınıza katılabilir. + Yeni grubunuz için bir ad ve isteğe bağlı bir fotoğraf ekleyin. + Grup Detayları + Grup Adı + Lütfen bir grup adı girin + Lütfen bir kanal adı girin + Profili Aç + İsmi Düzenle + Kişiyi Sil + Kişiyi Düzenle + Ad + Soyad + Kişi silinsin mi? + %1$s kişisini rehberinizden silmek istediğinizden emin misiniz? + Bu grupta gönderilen yeni mesajları belirli bir süre sonra otomatik olarak silin. + Fotoğraf ekle + Fotoğrafı değiştir + + + İzinler Gerekli + En iyi deneyimi sunmak için MonoGram\'ın aşağıdaki izinlere ihtiyacı vardır. + Bildirimler + Yeni mesajlardan haberdar olun + İzin Ver + Telefon Durumu + Daha iyi bir kullanıcı deneyimi için cihaz durumlarını yönetin + Pil Optimizasyonu + Arka planda güvenilir çalışma sağlayın + Devre Dışı Bırak + Kamera + Fotoğraf çekin ve video mesajlar kaydedin + Mikrofon + Sesli ve görüntülü mesajlar kaydedin + Konum + Konumunuzu paylaşın ve yakındaki kullanıcıları görün + + + Seçimi temizle + Sabitle + + + Bot Komutları + Bota göndermek için bir komut seçin + + + Sohbet Listesi + Konata Izumi + Kısa boylu değilim, sadece yoğunlaştırılmış harikalığım! 🍫 Ayrıca, profesyonel bir uykucu olmaya karar verdim 😴 + 12:45 + Kagami Hiiragi + Ödevi unutma! Yarın sabah teslim edilmesi gerekiyor ve oldukça zor. + 11:20 + + + Önizleme + Ben + Kısa boylu değilim, sadece yoğunlaştırılmış harikalığım! 🍫\nAyrıca, profesyonel bir uykucu olmaya karar verdim 😴 + En üst rafa yetişemediğin her seferde aynı şeyi söylüyorsun... 🙄\nŞuna bir bak: bu + Beni ifşa etmeyi bırak! 😤✨\nGizli silahımı kullanacağım: %100 saf tembellik + Süper etkili oldu! 😵‍💫 + Bugün + Konata + + %1$d abone + Konu + + + Kaydı Sil + < İptal etmek için kaydırın + Kaydı Gönder + Kaydı Kilitle + Yukarı kaydırın + + + Açıklama ekle… + Mesaj + Mesaj gönderimine izin verilmiyor + + + Fotoğraf + Video + Çıkartma + Sesli mesaj + Görüntülü mesaj + GIF + Konum + Mesaj + + + Yanıtlamayı iptal et + Mesajı Düzenle + Düzenlemeyi iptal et + %1$d öğeyi ekle + Medyayı gönder + İptal + Kopyala + Yapıştır + Görseli yapıştır + Kes + Tümünü seç + Uygula + Bitti + Yenile + Tam ekran düzenleyici + Düzenleyici + Sessiz gönder + Mesajı zamanla + Zamanlanmış mesajlar + Zamanlanmış mesajlar (%1$d) + Henüz zamanlanmış mesaj yok + Toplam zamanlanan: %1$d + Sonraki gönderim: %1$s + Düzenlenebilir: %1$d + Kimlik: %1$d + Düzenle + Gönder + Sil + Yönet + + Sınırlı medya erişimi etkin + Yalnızca seçilen fotoğraf ve videolar görünür. + Ekler + Diğer kaynaklar + Tümü + Fotoğraflar + Videolar + Tüm klasörler + Ekran görüntüleri + %1$d seçildi + Eklenmeye hazır + Medya erişimine izin ver + Sohbette dosya eklemek için fotoğraf ve videolara erişim izni verin. + Erişim izni ver + + %1$d karakter • %2$d biçim bloğu + %1$d biçim bloğu + Telegram\'daki gibi zengin biçimlendirme uygulamak için metni seçin + %1$d/%2$d + Geri Al + Yinele + Önizleme + Düzenle + Markdown: açık + Markdown: kapalı + A+ + A- + Snippet\'lar + Snippet olarak kaydet + Snippet başlığı + Henüz snippet yok + Bul + Değiştir + Tümünü değiştir + %1$d / %2$d eşleşme + Eşleşme yok + %1$d kelime + ~%1$d dk okuma + Taslak otomatik kaydedildi + Kopyala + Kes + Yapıştır + AI + AI düzenleyici + Çevir + Stille + Düzelt + Orijinal + Sonuç + Değişiklikler + Sonucu uygula + Metni çevir + Stili uygula + Metni düzelt + Hedef dil + Dil seçin + Dil bulunamadı + Resmî + Kısa + Kabile + Kurumsal + Dini + Viking + Zen + Emoji ekle + İşleniyor... + AI kullanmak için metin girin + Çok fazla AI isteği. Telegram Premium gerekebilir. + AI işleme başarısız oldu + Ekle + Önceki + Sonraki + + Kalın + İtalik + Altı Çizili + Üstü Çizili + Spoiler + Kod + Sabit Aralıklı + Bağlantı + Bahsetme + Emoji + Temizle + Bağlantı ekle + URL + Kod dili + Dil (örn. kotlin) + + + Komutlar + + + yazıyor + görüntülü mesaj kaydediyor + sesli mesaj kaydediyor + fotoğraf gönderiyor + video gönderiyor + dosya gönderiyor + çıkartma seçiyor + oyun oynuyor + Biri + ve + ve %d kişi daha + yazıyorlar + + %d kişi yazıyor + %d kişi yazıyor + + + +Fotoğraflar + Videolar + Belgeler + Çıkartmalar + Müzik + Sesli Mesajlar + Görüntülü Mesajlar + Diğer Dosyalar + Diğer / Önbellek + Sohbet %d + + + Aramalar + + + Yeni mesajlar bekleniyor + Durdur + Arka Plan Servisi + Uygulamanın arka planda çalıştığına dair bildirim + Yeni mesaj + Ben + %2$d sohbetten %1$d mesaj + %1$d sohbet + Sohbetler + Diğer + Özel sohbetler + Özel görüşmelerden gelen bildirimler + Gruplar + Gruplardan gelen bildirimler + Kanallar + Kanallardan gelen bildirimler + Diğer + Diğer bildirimler + + + 📷 Fotoğraf + 📹 Video + 🎤 Sesli mesaj + 🧩 Çıkartma + 📎 Belge + 🎵 Müzik + GIF + 🎬 Görüntülü mesaj + 👤 Kişi + 📊 Anket + 📍 Konum + 📞 Arama + 🎮 Oyun + 💳 Fatura + 🎬 Hikaye + 📌 Sabitlenmiş mesaj + Mesaj + Bot + Çevrimiçi + Çevrimdışı + Son görülme az önce + Son görülme %d dakika önce + Son görülme %d dakika önce + Son görülme %s + Son görülme dün %s + Son görülme %s + Son görülme yakınlarda + Son görülme bir hafta içinde + Son görülme bir ay içinde + + + Sabitlenmiş Mesajlar + Sabitlenmiş Mesaj + Tüm sabitlenenleri göster + Sabitlemeyi kaldır + Kapat + + %d mesaj + %d mesaj + + + + Görüntülü mesaj + GIF + Belge + Anket: %s + Çıkartma %s + + + Görünüm + Kırp + Filtreler + Çiz + Metin + Silgi + + + Orijinal + Siyah Beyaz + Sepya + Eskitme + Soğuk + Sıcak + Polaroid + Ters Çevir + + + Kaydet + İptal + Geri Al + Yinele + Kapat + Sıfırla + Sola Döndür + Sağa Döndür + Metin Ekle + Metni Düzenle + Uygula + Sil + + + Boyut + Yakınlaştırma + Bir şeyler yazın... + Düzenlemeye başlamak için bir araç seçin + + + Değişiklikler atılsın mı? + Kaydedilmemiş değişiklikleriniz var. Bunları iptal etmek istediğinizden emin misiniz? + Vazgeç ve At + + + Uygula + Bitti + İptal + Düşük + + + Görünüm + Kes + Filtreler + Metin + Sıkıştır + + + Sesi Aç + Sesi Kapat + Metin Katmanı Ekle + Video Kalitesi + Tahmini Bit Hızı: %1$d kbps + + + Orijinal + Siyah Beyaz + Sepya + Eskitme + Soğuk + Sıcak + Polaroid + Ters Çevir + + + Dosya bulunamadı + Video dosyası eksik + Değişiklikler atılsın mı? + Kaydedilmemiş değişiklikleriniz var. Bunları iptal etmek istediğinizden emin misiniz? + Vazgeç ve At + + + Yükleniyor… + Web Görünümü + Kapat + Diğer seçenekler + + + Seçenekler + Geri + İleri + Yenile + Eylemler + Ayarlar + Kopyala + Bağlantı kopyalandı + Paylaş + Bağlantıyı şununla paylaş: + Tarayıcıda aç + Sayfada bul + Masaüstü sitesi + Reklamları engelle + Metin boyutu: %%1$d + + + Güvenli + Güvenli değil + Güvenlik Bilgileri + Güvenli Olmayan Bağlantı + Bu siteyle olan bağlantınız şifrelenmiştir ve güvenlidir. + Bu siteyle olan bağlantınız güvenli değil. Parola veya kredi kartı gibi hassas bilgilerinizi girmemelisiniz; çünkü bu bilgiler saldırganlar tarafından çalınabilir. + Şu tarafa verildi: + Şu tarafça verildi: + Şu tarihe kadar geçerli: + Bilinmiyor + + + Sayfada bul… + Önceki + Sonraki + Aramayı Kapat + + + Tartışma + Kanal + Haritaları Aç + Yol Tarifi + Şununla navigasyon yap: + Şununla aç: + Tarayıcı / Diğer + + Medya + Üyeler + Dosyalar + Müzik + Ses + Bağlantılar + GIF\'ler + Üye bulunamadı + Medya bulunamadı + Müzik dosyası bulunamadı + Sesli mesaj bulunamadı + Dosya bulunamadı + Bağlantı bulunamadı + GIF bulunamadı + + BOT + DOLANDIRICI + SAHTE + Resmî olmayan uygulama kullanıyor + Bu hesap resmî olmayan bir Telegram istemcisi kullanıyor + Bot doğrulaması + Üçüncü taraf bir bot tarafından doğrulandı + Kapalı + ID + + + İstatistikler Analiz Ediliyor... + Genel Bakış + Üyeler + Mesajlar + İzleyiciler + Aktif Göndericiler + Üye Büyümesi + Yeni Üyeler + Mesaj İçeriği + Eylemler + Günlük Aktivite + Haftalık Aktivite + En Aktif Saatler + Kaynağa Göre Görüntülenme + Kaynağa Göre Yeni Üyeler + Diller + En Çok Gönderenler + mesaj + Ort. karakter: %1$d + En Aktif Yöneticiler + eylem + Silinen: %1$d | Yasaklanan: %2$d + En Çok Davet Edenler + davet + Eklenen üyeler + Aboneler + Bildirimler Açık + Ort. Mesaj Görüntülenme + Ort. Mesaj Paylaşımı + Ort. Tepkiler + Büyüme + Yeni Aboneler + Saate Göre Görüntülenme + Mesaj Etkileşimleri + Hızlı Görünüm Etkileşimleri + Mesaj Tepkileri + Son Etkileşimler + Mesaj + Hikaye + Gönderi Kimliği + Daha Az Göster + Tümünü Göster (%1$d) + + Gelir + Kullanılabilir Bakiye + Toplam Bakiye + Döviz Kuru + Gelir Büyümesi + Saatlik Gelir + Analizler Yüklendi + + Yakınlaştır + Grafik Oluşturuluyor... + Değişim yok + önceki döneme göre + Bilinmeyen İstatistik Türü + Veri sınıfı: %1$s + + Kişi: %1$s + Mekan: %1$s + Anket: %1$s + Servis mesajı + + + Geri + Seçenekler + 10 saniye geri sar + 10 saniye ileri sar + -%1$ds + +%1$ds + Küçük resim %1$d + %1$d / %2$d + Orijinal yükleniyor... + + + İndir + Videoyu İndir + Görseli Kopyala + Metni Kopyala + Bağlantıyı Kopyala + Zaman Bilgisiyle Kopyala + İlet + GIF\'lere Kaydet + Galeriye Kaydet + Panoya Kopyala + Yeniden Başlat + Duraklat + Oynat + Kilidi Aç + + + Ayarlar + Oynatma Hızı + Ölçeklendirme Modu + Sığdır + Yakınlaştır + Ekranı Döndür + Pencere İçinde Pencere (PiP) + Ekran Görüntüsü Al + Videoyu Döngüye Al + Sesi Kapat + Altyazılar + Kontrolleri Kilitle + Kalite + Video Kalitesi + Otomatik + Yüksek Çözünürlük + Normal + + + Oynat + Duraklat + Geri sar + İleri sar + + + Çıkartmalar + Emojiler + GIF\'ler + + + Son Kullanılanlar + Çıkartmalar + Çıkartma ara + + + Son Kullanılanlar + Standart Emojiler + Özel Emojiler + Emojiler + Emoji ara + + + Son Kullanılanlar ve Kaydedilenler + GIF bulunamadı + GIF ara + + + Geri + Temizle + Son Kullanılanlar + + + Galeriye kaydedildi + QR Paylaş + Gönderilirken hata oluştu: %1$s + + + Kanalı Düzenle + Grubu Düzenle + Kanal Adı + Grup Adı + Açıklama + Ayarlar + Genel Kanal + Genel Grup + Otomatik Çeviri + Konular + Yönetim + Kanalı Sil + Grubu Sil + + + Ara... + Yöneticiler + Aboneler + Üyeler + Kara Liste + Sonuç bulunamadı + Henüz üye yok + + + Yönetici Yetkileri + Özel Başlık + Bu başlık, sohbetteki tüm üyeler tarafından görülebilir + Bu yönetici neler yapabilir? + Sohbeti Yönet + Mesaj Gönder + Mesajları Düzenle + Mesajları Sil + Üyeleri Kısıtla + Kullanıcı Davet Et + Konuları Yönet + Görüntülü Sohbetleri Yönet + Hikaye Paylaş + Hikayeleri Düzenle + Hikayeleri Sil + Yeni Yönetici Ekle + Anonim Kal + + + İzinler + Bu grubun üyeleri neler yapabilir? + Üye Ekle + + + Geri + Kaydet + Temizle + Ara + Ekle + Düzenle + Kullanıcı adı + Filtreler + + + Son Etkinlikler + %d etkinlik yüklendi + Yakın zamanda gerçekleşen bir etkinlik bulunamadı + Filtreleri değiştirmeyi deneyin + Henüz uygulanmadı + Kullanıcı kimliği kopyalandı + + + Eylemleri Filtrele + Sıfırla + Uygula + Eylem Türleri + Kullanıcıya Göre + Ara… + Kullanıcı bulunamadı + \"%s\" için sonuç yok + + + Düzenlemeler + Silmeler + Sabitlemeler + Katılmalar + Ayrılmalar + Davetler + Yetkilendirmeler + Kısıtlamalar + Bilgiler + Ayarlar + Bağlantılar + Video + + + bir mesaj düzenledi + bir mesaj sildi + bir mesaj sabitledi + bir mesajın sabitlemesini kaldırdı + sohbete katıldı + sohbetten ayrıldı + %s kullanıcısını davet etti + %s için izinleri değiştirdi + %s için kısıtlamaları değiştirdi + sohbet adını \"%s\" olarak değiştirdi + sohbet açıklamasını değiştirdi + kullanıcı adını @%s olarak değiştirdi + sohbet fotoğrafını değiştirdi + davet bağlantısını düzenledi + davet bağlantısını iptal etti + davet bağlantısını sildi + görüntülü sohbet başlattı + görüntülü sohbeti bitirdi + bir eylem gerçekleştirdi: %s + + + Orijinal mesaj: + Yeni mesaj: + Silinen mesaj: + Sabitlenen mesaj: + Sabitlemesi kaldırılan mesaj: + Bitiş: %s + Kalıcı olarak kısıtlandı + Eski + Yeni + Eski sohbet fotoğrafı + Yeni sohbet fotoğrafı + Kaynak + Hedef + İzin değişiklikleri: + Mevcut izinler: + + + Mesajlar + Medya + Çıkartmalar + Bağlantılar + Anketler + Davet + Sabitleme + Bilgi + + Yönetici + Sahip + Kısıtlı + Yasaklı + Üye + + + Fotoğraf + Video + GIF + Çıkartma + Dosya + Müzik + Sesli mesaj + Görüntülü mesaj + Kişi + Anket + Konum + Mekan + Desteklenmeyen mesaj + + + %1$02d:%2$02d + + + %1$.1fB + %1$.1fM + + + Yorum yap + %1$d yorum + %1$.1fB yorum + %1$.1fM yorum + + + Sonuçlar + Anonim + Genel + Test + Anket + • Çoklu Seçim + Oyumu Geri Çek + Anketi Kapat + + %d oy + %d oy + + + + Daha fazla + Oy ver + Açıklama + + + Oylayanlar + Henüz oy veren yok + Kapat + + + Fotoğraf + Video + Çıkartma + Sesli mesaj + Görüntülü mesaj + GIF + Mesaj + + + Sohbet Klasörleri + Geri + Yeni Klasör + Oluştur + Klasörü Düzenle + Kaydet + Varsayılan Klasörler + Özel Klasörler + Sil + Farklı sohbet grupları için klasörler oluşturun ve bunlar arasında hızlıca geçiş yapın. + Özel klasör yok + Oluşturmak için + düğmesine dokunun + Tüm sohbetler + %1$d sohbet + Yukarı Taşı + Aşağı Taşı + Klasör Adı + Simge Seç + Dahil Edilen Sohbetler + Sohbet ara… + İptal + + + Telegram Premium + Geri + Aylık %s karşılığında abone ol + Özel özelliklerin kilidini aç + + +İki Kat Limitler + %1$d kanala kadar, %2$d sohbet klasörü, %3$d sabitleme, %4$d genel bağlantı ve daha fazlası. + Sesi Metne Dönüştürme + Yanındaki düğmeye dokunarak herhangi bir sesli mesajın metin dökümünü okuyun. + Daha Hızlı İndirme + Medya ve dosyaların indirilme hızındaki tüm limitler kalkıyor. + Gerçek Zamanlı Çeviri + Tüm sohbetleri tek bir dokunuşla gerçek zamanlı olarak çevirin. + Hareketli Emojiler + Yüzlerce paketteki hareketli emojileri mesajlarınıza dahil edin. + Gelişmiş Sohbet Yönetimi + Varsayılan klasörü ayarlama, otomatik arşivleme ve yeni sohbetleri gizleme araçları. + Reklam Yok + Genel kanallarda bazen gösterilen reklamlar artık karşınıza çıkmayacak. + Sonsuz Tepki + Binlerce emoji ile tepki verin; her mesajda 3 taneye kadar kullanın. + Premium Rozeti + Adınızın yanında Telegram Premium abonesi olduğunuzu gösteren özel bir rozet. + Emoji Durumları + Adınızın yanında görünecek binlerce emoji arasından seçim yapın. + Premium Uygulama Simgeleri + Ana ekranınız için çeşitli Telegram uygulama simgeleri arasından seçim yapın. + + + Yenile + Bağlantıyı kopyala + Tarayıcıda aç + Ana Ekrana Ekle + + + Mini Uygulama Kapatılsın mı? + Kaydedilmemiş değişiklikleriniz var. Kapatmak istediğinizden emin misiniz? + Kapat + İptal + İzin İsteği + İzin Ver + Reddet + Biyometri + Kişiyi Paylaş + %1$s uygulamasının telefon numaranıza erişmesine izin verilsin mi? + Mesajlara İzin Ver + %1$s uygulamasının size mesaj göndermesine izin verilsin mi? + Dosyayı İndir + Bu dosya indirilsin mi? + %1$s indirilsin mi? + Biyometrik Kimlik Doğrulama + Devam etmek için kimliğinizi doğrulayın + Bu botun konumunuza erişmesine izin verilsin mi? + Bot İzinleri + Hizmet Koşulları + Bu Mini Uygulamayı başlatarak Hizmet Koşullarını ve Gizlilik Politikasını kabul etmiş olursunuz. Bot, temel profil bilgilerinize erişebilecektir. + Kabul Et ve Başlat + + + Makalede ara… + Geri + Temizle + Hızlı Görünüm + Daha fazla + %d görüntülenme + Bağlantıyı kopyala + Tarayıcıda aç + Ara + Metin Boyutu + Videoyu oynat + Animasyonu oynat + Ses + Bilinmeyen Sanatçı + Oynat + + Daralt + Genişlet + Harita: %1$s, %2$s + + + Profili Paylaş + Monogram profil bağlantınızı paylaşın + Bağlantıyı Kopyala + Monogram profil bağlantınızı panoya kopyalayın + + + Tema Düzenleyici + Palet Modu + Hangi paleti düzenleyeceğinizi seçin. Düzenleyici, uygulamada şu an aktif olan palet üzerinden açılır. + Açık + Koyu + Düzenlenen: %1$s paleti + Uygulamada şu an aktif: %1$s + Vurgu + Vurgu rengi sadece %1$s paletini günceller. + Hex vurgusu + Uygula + İptal + Seç + Kaydet + Yükle + Her İkisi + Hex + monogram-tema.json + + Tema dosyası kaydedildi + Kaydedilemedi + Tema yüklendi + Geçersiz dosya + Yüklenemedi + + Tema Kaynağı + Uygulama renkleri için tam olarak bir kaynak seçin. "Özel", düzenlenebilir paletlerinizi; "Monet" ise Android dinamik renklerini kullanır. AMOLED seçeneği sadece koyu arka planları etkiler. + Özel tema + Kendi Açık/Koyu paletlerinizi, vurgu renklerinizi, hazır ayarlarınızı ve manuel rol renklerinizi kullanın. + Monet + Duvar kağıdına dayalı sistem tarafından oluşturulan Material You renklerini kullanın (Android 12+). + AMOLED koyu + OLED ekranlarda parlamayı azaltmak ve güç tasarrufu sağlamak için koyu yüzeylerde tam siyahı zorunlu kılın. + + Hazır Temalar + Her hazır ayarın Açık ve Koyu varyantları mevcuttur. Anında uygulamak için Açık, Koyu veya Her İkisi seçeneğine dokunun. + Manuel Renkler (%1$s) + %1$s önizleme + Özet metin + Eylem + %1$s seçin + Renk Tonu %1$d° + Doygunluk %%%1$d + Parlaklık %%%1$d + Alfa (Şeffaflık) %%%1$d + + Birincil + İkincil + Üçüncül + Arka Plan + Yüzey + Birincil Kap + İkincil Kap + Üçüncül Kap + Yüzey Varyantı + Kenarlık + + A B + A K + A AP + K B + K K + K AP + + Mavi + Yeşil + Turuncu + Gül Kurusu + Çivit Mavisi + Turkuaz + + Klasik + Net okunabilirlik ve nötr yüzeylerle dengelenmiş mavi. + + Orman + Sakin kontrast ve yumuşak kap tonlarıyla doğal yeşiller. + + Okyanus + Taze açık ve derin koyu mod ile serin turkuaz-mavi gradyan hissi. + + Gün Batımı + Yüksek ön plan netliği ile sıcak turuncu ve mercan vurgu seti. + + Grafit + Mavi-gri vurgular ve güçlü yapıya sahip nötr gri tonlamalı taban. + + Nane + Nazik kaplarla taze nane ve cam göbeği kombinasyonu. + + Yakut + Güçlü eylem odaklılık için nötr kaplara sahip derin kırmızı vurgu. + + Lavanta Grisi + Kontrollü doygunluk ve temiz kontrasta sahip mat mor-gri şema. + + Kum + Çok yumuşak görsel gürültü için ayarlanmış bej ve kehribar paleti. + + Arktik + Yüksek okunabilirlik ve keskin sınırlarla buzlu mavi-beyaz hissi. + + Zümrüt + Temiz yüzeyler ve modern kontrasta sahip canlı yeşil paleti. + + Bakır + Her iki modda da metni net tutan sıcak bakır-turuncu tema. + + Sakura + Yumuşak kaplar ve güçlü vurgularla nazik pembe-macenta tonları. + + Nord + Sakin ve profesyonel bir görsel dengeye sahip soğuk kuzey mavileri. + + + Kamera ve mikrofon izinleri gerekiyor + İşleniyor… + İşleme hatası: %1$s + Kaydediciyi kapat + Kamerayı değiştir + Kaydı bitir + KAYIT + HAZIR + Süre: %1$s + Yakınlaştırma: %1$.1fx (aralık %2$.1fx - %3$.1fx) + İptal + + Geçmiş Temizlensin mi? + Sohbet geçmişini temizlemek istediğinizden emin misiniz? Bu işlem geri alınamaz. + Geçmişi Temizle + Sohbet Silinsin mi? + Bu sohbeti silmek istediğinizden emin misiniz? Bu işlem geri alınamaz. + Sohbeti Sil + Sohbetten Ayrıl? + Bu sohbetten ayrılmak istediğinizden emin misiniz? + Ayrıl + Kanalı Sil? + Grubu Sil? + Bu kanalı silmek istediğinizden emin misiniz? Tüm mesajlar ve medya içerikleri kaybolacaktır. + Bu grubu silmek istediğinizden emin misiniz? Tüm mesajlar ve medya içerikleri kaybolacaktır. + %1$d sohbet silinsin mi? + Seçilen sohbetleri silmek istediğinizden emin misiniz? + Sohbetleri Sil + Kullanıcı Engellensin mi? + Bu kullanıcıyı engellemek istediğinizden emin misiniz? Bu kullanıcı size mesaj gönderemeyecek. + Engelle + Engeli Kaldır? + Bu kullanıcının engelini kaldırmak istediğinizden emin misiniz? Size tekrar mesaj gönderebilecekler. + Mesaj Sabitlemesi Kaldırılsın mı? + Bu mesajın sabitlemesini kaldırmak istediğinizden emin misiniz? + Sabitlemeyi Kaldır + Son kullanılan çıkartmaları temizlemek istediğinizden emin misiniz? + Çıkartmaları Temizle + Son kullanılan emojileri temizlemek istediğinizden emin misiniz? + Emojileri Temizle + Kişilere ekle + Kişilerden çıkar + Okundu olarak işaretle + Okunmadı olarak işaretle + Düzenle + Yeniden Sırala + Sil + Geçersiz onay kodu + Geçersiz şifre + Beklenmedik bir hata oluştu + diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index ab87273f..329a52ec 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -231,6 +231,7 @@ Закріпити Відкріпити Переслати + +1 Вибрати Ще Видалити diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 4a11de8a..41833daa 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -231,6 +231,7 @@ 置顶 取消置顶 转发 + +1 选择 更多 删除 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index d300c61d..dd863ca3 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -233,6 +233,7 @@ Pin Unpin Forward + +1 Select More Delete @@ -380,6 +381,12 @@ Open sponsor info bottom sheet Force sponsor sync Fetch sponsor IDs from channel now + Push Diagnostics + Runtime Flags + Push Environment + UnifiedPush Details + Sponsor + Danger Zone Log Out Disconnect from account @@ -549,7 +556,7 @@ Edit Proxy Server Address Port - Secret (Hex) + Secret Username (Optional) Password (Optional) Save @@ -611,6 +618,7 @@ Every 1 hour Every %1$d hours Firebase Cloud Messaging + UnifiedPush (Simple Push) GMS-less (Background Service)