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 d26b748f5..23abb3bc0 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
@@ -37,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
@@ -84,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
}
@@ -143,12 +155,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 +172,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, CONTACT_REFRESH_DEBOUNCE_MS)
+ }
+ }
+ 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()
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+