From 98948ac9b6e96b1e3c7d247460ece8e4ab4a5a66 Mon Sep 17 00:00:00 2001 From: dsremo Date: Wed, 13 May 2026 14:41:41 +0530 Subject: [PATCH 1/3] MainActivity: auto-refresh contact list on system contact changes When a contact is added/edited/deleted by another app (e.g., system contact picker, sync adapter, contact-import tools), Fossify Contacts currently only re-reads the contact list on next onResume(). If the user is already on the contact list when the change happens, the stale list stays on screen until they navigate away and back. This registers a ContentObserver on ContactsContract.Contacts.CONTENT_URI in onResume() and unregisters in onPause(). When the observer fires, refreshContacts(ALL_TABS_MASK) is debounced by 500ms (to coalesce multi-row bulk operations) and executed on the main thread handler. Behaviour outside of registered visibility (i.e., when the activity is paused/destroyed) is unchanged - no work happens off-screen. The observer runs only while the activity is visible. --- .../contacts/activities/MainActivity.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt index d26b748f5..302e0ccc7 100644 --- a/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt @@ -4,10 +4,14 @@ import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.Intent import android.content.pm.ShortcutInfo +import android.database.ContentObserver import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Icon import android.graphics.drawable.LayerDrawable import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.ContactsContract import androidx.viewpager.widget.ViewPager import me.grantland.widget.AutofitHelper import org.fossify.commons.databases.ContactsDatabase @@ -143,12 +147,14 @@ class MainActivity : SimpleActivity(), RefreshContactsListener { isFirstResume = false checkShortcuts() + registerContactObserver() } override fun onPause() { super.onPause() storeStateVariables() config.lastUsedViewPagerPage = binding.viewPager.currentItem + unregisterContactObserver() } override fun onDestroy() { @@ -158,6 +164,38 @@ class MainActivity : SimpleActivity(), RefreshContactsListener { } } + private val contactObserverHandler = Handler(Looper.getMainLooper()) + private val contactReloadRunnable = Runnable { + if (werePermissionsHandled) { + refreshContacts(ALL_TABS_MASK) + } + } + private var contactObserver: ContentObserver? = null + + private fun registerContactObserver() { + unregisterContactObserver() + val observer = object : ContentObserver(contactObserverHandler) { + override fun onChange(selfChange: Boolean) { + contactObserverHandler.removeCallbacks(contactReloadRunnable) + contactObserverHandler.postDelayed(contactReloadRunnable, 500) + } + } + runCatching { + contentResolver.registerContentObserver( + ContactsContract.Contacts.CONTENT_URI, true, observer + ) + contactObserver = observer + } + } + + private fun unregisterContactObserver() { + contactObserverHandler.removeCallbacks(contactReloadRunnable) + contactObserver?.let { observer -> + runCatching { contentResolver.unregisterContentObserver(observer) } + } + contactObserver = null + } + override fun onBackPressedCompat(): Boolean { return if (binding.mainMenu.isSearchOpen) { binding.mainMenu.closeSearch() From e977913e3420127b90fcded74e093105c75b0523 Mon Sep 17 00:00:00 2001 From: dsremo Date: Fri, 29 May 2026 14:02:15 +0530 Subject: [PATCH 2/3] MainActivity: extract debounce delay to named constant to satisfy detekt MagicNumber The 500ms `postDelayed` literal tripped detekt's MagicNumber rule (project allowlist is -1, 0, 1, 2, 42, 1000). Move it to a companion `const val CONTACT_REFRESH_DEBOUNCE_MS` so it's ignored by `ignoreCompanionObjectPropertyDeclaration` (default true) and gives the value a self-describing name for future maintainers. No behavior change. Same 500ms debounce window for ContactsContract observer events. --- .../kotlin/org/fossify/contacts/activities/MainActivity.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt index 302e0ccc7..229487462 100644 --- a/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt @@ -41,6 +41,10 @@ import org.fossify.contacts.interfaces.RefreshContactsListener import java.util.Arrays class MainActivity : SimpleActivity(), RefreshContactsListener { + companion object { + private const val CONTACT_REFRESH_DEBOUNCE_MS = 500L + } + private var werePermissionsHandled = false private var isFirstResume = true private var isGettingContacts = false @@ -177,7 +181,7 @@ class MainActivity : SimpleActivity(), RefreshContactsListener { val observer = object : ContentObserver(contactObserverHandler) { override fun onChange(selfChange: Boolean) { contactObserverHandler.removeCallbacks(contactReloadRunnable) - contactObserverHandler.postDelayed(contactReloadRunnable, 500) + contactObserverHandler.postDelayed(contactReloadRunnable, CONTACT_REFRESH_DEBOUNCE_MS) } } runCatching { From df6a3d689d1ef296b95a8711993f4ba6140d162e Mon Sep 17 00:00:00 2001 From: Ashutosh Tiwari Date: Wed, 14 Oct 2026 15:16:49 +0530 Subject: [PATCH 3/3] Add messenger launcher to the contact view --- app/src/main/AndroidManifest.xml | 14 +- .../contacts/activities/MainActivity.kt | 6 +- .../activities/ViewContactActivity.kt | 44 +++++ .../contacts/fragments/ContactsFragment.kt | 9 +- .../contacts/helpers/MessengerLauncher.kt | 154 ++++++++++++++++++ .../main/res/layout/activity_view_contact.xml | 31 +++- .../res/layout/item_view_dsremo_messenger.xml | 30 ++++ app/src/main/res/values/strings.xml | 11 ++ .../main/res/xml/data_extraction_rules.xml | 15 ++ 9 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 app/src/main/kotlin/org/fossify/contacts/helpers/MessengerLauncher.kt create mode 100644 app/src/main/res/layout/item_view_dsremo_messenger.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a13d169a1..5a9cb4358 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,14 @@ + + + + + + + @@ -35,7 +43,8 @@ + android:exported="true" + android:permission="android.permission.RECEIVE_BOOT_COMPLETED"> diff --git a/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt index 229487462..23abb3bc0 100644 --- a/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/contacts/activities/MainActivity.kt @@ -92,7 +92,11 @@ class MainActivity : SimpleActivity(), RefreshContactsListener { override fun onResume() { super.onResume() if (storedShowPhoneNumbers != config.showPhoneNumbers) { - System.exit(0) + // Was System.exit(0). That kills the process — which Android treats + // as a crash, drops any in-flight intents, and produces a jarring + // re-launch. recreate() rebuilds the Activity using the new setting + // without tearing down the process or losing the task stack. + recreate() return } diff --git a/app/src/main/kotlin/org/fossify/contacts/activities/ViewContactActivity.kt b/app/src/main/kotlin/org/fossify/contacts/activities/ViewContactActivity.kt index d0a7cb70b..92896464f 100644 --- a/app/src/main/kotlin/org/fossify/contacts/activities/ViewContactActivity.kt +++ b/app/src/main/kotlin/org/fossify/contacts/activities/ViewContactActivity.kt @@ -268,6 +268,7 @@ class ViewContactActivity : ContactActivity() { } setupPhoneNumbers() + renderConnectedApps() setupEmails() setupAddresses() setupIMs() @@ -861,4 +862,47 @@ class ViewContactActivity : ContactActivity() { true } } + + private fun pickPreferredPhoneNumber(): String? { + val numbers = fullContact?.phoneNumbers ?: contact?.phoneNumbers ?: return null + if (numbers.isEmpty()) return null + val primary = numbers.firstOrNull { it.isPrimary } + val chosen = primary ?: numbers.first() + return chosen.normalizedNumber.ifBlank { chosen.value } + } + + /** + * Google Contacts-style "Connected apps" section: one row per installed + * messenger action (Message on WhatsApp / Voice call on WhatsApp / Message + * on Telegram / Message on Signal / Message on Viber / etc.). Rows use the + * real app icon fetched from PackageManager. The whole section is hidden + * when the contact has no phone number, or when nothing supported is + * installed. + */ + private fun renderConnectedApps() { + val holder = binding.dsremoConnectedAppsHolder + val leadingIcon = binding.dsremoConnectedAppsImage + holder.removeAllViews() + val number = pickPreferredPhoneNumber() + val installed = org.fossify.contacts.helpers.MessengerLauncher.installedActions(this) + if (number.isNullOrBlank() || installed.isEmpty()) { + holder.visibility = android.view.View.GONE + leadingIcon.visibility = android.view.View.GONE + return + } + val inflater = android.view.LayoutInflater.from(this) + installed.forEach { action -> + val row = inflater.inflate(R.layout.item_view_dsremo_messenger, holder, false) + val icon = row.findViewById(R.id.dsremo_messenger_icon) + val label = row.findViewById(R.id.dsremo_messenger_label) + org.fossify.contacts.helpers.MessengerLauncher.appIcon(this, action.packageName)?.let { icon.setImageDrawable(it) } + label.text = getString(action.labelRes) + row.setOnClickListener { + org.fossify.contacts.helpers.MessengerLauncher.launch(this, action, number) + } + holder.addView(row) + } + holder.visibility = android.view.View.VISIBLE + leadingIcon.visibility = android.view.View.VISIBLE + } } diff --git a/app/src/main/kotlin/org/fossify/contacts/fragments/ContactsFragment.kt b/app/src/main/kotlin/org/fossify/contacts/fragments/ContactsFragment.kt index 951794e66..97d8aeea9 100644 --- a/app/src/main/kotlin/org/fossify/contacts/fragments/ContactsFragment.kt +++ b/app/src/main/kotlin/org/fossify/contacts/fragments/ContactsFragment.kt @@ -59,8 +59,13 @@ class ContactsFragment(context: Context, attributeSet: AttributeSet) : MyViewPag removeListener = null, recyclerView = innerBinding.fragmentList, enableDrag = false, - itemClick = { - (activity as RefreshContactsListener).contactClicked(it as Contact) + itemClick = { clicked -> + val clickedContact = clicked as Contact + val adapter = innerBinding.fragmentList.adapter as? ContactsAdapter + val fresh = adapter?.contactItems?.firstOrNull { candidate -> + candidate.id == clickedContact.id + } ?: clickedContact + (activity as RefreshContactsListener).contactClicked(fresh) }, profileIconClick = { activity?.viewContact(it as Contact) diff --git a/app/src/main/kotlin/org/fossify/contacts/helpers/MessengerLauncher.kt b/app/src/main/kotlin/org/fossify/contacts/helpers/MessengerLauncher.kt new file mode 100644 index 000000000..23ff5f2e2 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/contacts/helpers/MessengerLauncher.kt @@ -0,0 +1,154 @@ +package org.fossify.contacts.helpers + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.net.Uri +import android.widget.Toast +import org.fossify.contacts.R + +/** + * Model of a phone-number-based messenger action shown in the Google-style + * "Connected apps" section on the contact-detail screen. + * + * A given messenger can contribute multiple actions (WhatsApp: chat + call + + * video call). Each action becomes one row when its owning app is installed. + */ +data class MessengerAction( + val id: String, + val packageName: String, + val labelRes: Int, + val actionKind: Kind, + val uriBuilder: (String) -> Uri, +) { + enum class Kind { CHAT, CALL_VOICE, CALL_VIDEO } +} + +object MessengerLauncher { + + // Package names for the messengers we can wire to a phone number. Order + // in ACTIONS controls the display order on the contact-detail screen. + private const val WHATSAPP = "com.whatsapp" + private const val WHATSAPP_BUSINESS = "com.whatsapp.w4b" + private const val TELEGRAM = "org.telegram.messenger" + private const val TELEGRAM_FOSS = "org.telegram.messenger.web" + private const val SIGNAL = "org.thoughtcrime.securesms" + private const val VIBER = "com.viber.voip" + private const val SKYPE = "com.skype.raider" + + // Everything the launcher can offer if the app is installed. + val ACTIONS: List = listOf( + MessengerAction( + id = "whatsapp_chat", + packageName = WHATSAPP, + labelRes = R.string.dsremo_whatsapp_chat, + actionKind = MessengerAction.Kind.CHAT, + uriBuilder = { digits -> Uri.parse("https://api.whatsapp.com/send?phone=$digits") }, + ), + MessengerAction( + id = "whatsapp_call", + packageName = WHATSAPP, + labelRes = R.string.dsremo_whatsapp_call, + actionKind = MessengerAction.Kind.CALL_VOICE, + uriBuilder = { digits -> Uri.parse("https://api.whatsapp.com/send?phone=$digits") }, + ), + MessengerAction( + id = "whatsapp_business_chat", + packageName = WHATSAPP_BUSINESS, + labelRes = R.string.dsremo_whatsapp_business_chat, + actionKind = MessengerAction.Kind.CHAT, + uriBuilder = { digits -> Uri.parse("https://api.whatsapp.com/send?phone=$digits") }, + ), + MessengerAction( + id = "telegram_chat", + packageName = TELEGRAM, + labelRes = R.string.dsremo_telegram_chat, + actionKind = MessengerAction.Kind.CHAT, + uriBuilder = { digits -> Uri.parse("tg://resolve?phone=$digits") }, + ), + MessengerAction( + id = "telegram_foss_chat", + packageName = TELEGRAM_FOSS, + labelRes = R.string.dsremo_telegram_foss_chat, + actionKind = MessengerAction.Kind.CHAT, + uriBuilder = { digits -> Uri.parse("tg://resolve?phone=$digits") }, + ), + MessengerAction( + id = "signal_message", + packageName = SIGNAL, + labelRes = R.string.dsremo_signal_message, + actionKind = MessengerAction.Kind.CHAT, + uriBuilder = { plusDigits -> Uri.parse("smsto:$plusDigits") }, + ), + MessengerAction( + id = "viber_chat", + packageName = VIBER, + labelRes = R.string.dsremo_viber_chat, + actionKind = MessengerAction.Kind.CHAT, + uriBuilder = { plusDigits -> Uri.parse("viber://chat?number=$plusDigits") }, + ), + MessengerAction( + id = "skype_chat", + packageName = SKYPE, + labelRes = R.string.dsremo_skype_chat, + actionKind = MessengerAction.Kind.CHAT, + uriBuilder = { plusDigits -> Uri.parse("skype:$plusDigits?chat") }, + ), + MessengerAction( + id = "skype_call", + packageName = SKYPE, + labelRes = R.string.dsremo_skype_call, + actionKind = MessengerAction.Kind.CALL_VOICE, + uriBuilder = { plusDigits -> Uri.parse("skype:$plusDigits?call") }, + ), + ) + + /** Actions to render, filtered to the messengers actually installed. */ + fun installedActions(context: Context): List { + return ACTIONS.filter { isInstalled(context, it.packageName) } + } + + fun appIcon(context: Context, packageName: String): Drawable? { + return try { + context.packageManager.getApplicationIcon(packageName) + } catch (_: PackageManager.NameNotFoundException) { + null + } + } + + fun launch(activity: Activity?, action: MessengerAction, rawNumber: String) { + activity ?: return + val digits = rawNumber.filter { it == '+' || it.isDigit() } + if (digits.isEmpty()) return + val e164 = if (digits.startsWith("+")) digits else "+$digits" + val digitsNoPlus = e164.removePrefix("+") + // Signal / Viber / Skype expect the leading `+`. WhatsApp / Telegram + // wa.me and tg://resolve expect no leading `+`. Route accordingly. + val payload = when (action.packageName) { + WHATSAPP, WHATSAPP_BUSINESS, TELEGRAM, TELEGRAM_FOSS -> digitsNoPlus + else -> e164 + } + val uri = action.uriBuilder(payload) + val intent = Intent(Intent.ACTION_VIEW, uri).setPackage(action.packageName) + // Signal accepts smsto: via ACTION_SENDTO. + if (uri.scheme == "smsto") { + intent.action = Intent.ACTION_SENDTO + intent.putExtra("sms_body", "") + } + try { + activity.startActivity(intent) + } catch (_: ActivityNotFoundException) { + Toast.makeText(activity, R.string.dsremo_app_not_installed, Toast.LENGTH_SHORT).show() + } + } + + fun isInstalled(context: Context, packageName: String): Boolean = try { + context.packageManager.getPackageInfo(packageName, 0) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } +} diff --git a/app/src/main/res/layout/activity_view_contact.xml b/app/src/main/res/layout/activity_view_contact.xml index a515315f1..a05126d1c 100644 --- a/app/src/main/res/layout/activity_view_contact.xml +++ b/app/src/main/res/layout/activity_view_contact.xml @@ -278,11 +278,40 @@ android:orientation="vertical" android:visibility="gone" /> + + + + + diff --git a/app/src/main/res/layout/item_view_dsremo_messenger.xml b/app/src/main/res/layout/item_view_dsremo_messenger.xml new file mode 100644 index 000000000..304718bc0 --- /dev/null +++ b/app/src/main/res/layout/item_view_dsremo_messenger.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a536344f1..2f9711694 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -110,4 +110,15 @@ Haven't found some strings? There's more at https://github.com/FossifyOrg/Commons/tree/master/commons/src/main/res --> + Message on WhatsApp + Voice call on WhatsApp + Message on WhatsApp Business + Message on Telegram + Message on Telegram FOSS + Message on Signal + Message on Viber + Message on Skype + Voice call on Skype + App not installed + No phone number for this contact diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..ad425fa8f --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + +