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
+
+
+
+
+
+**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
+
+
+
+| | | | |
+|:---:|:---:|:---:|:---:|
+|

|

|

|

|
+
+
+
+---
+
+## Ö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
+ Aç
+ 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
+ Aç
+ 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)