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 @@ + + + + + + + + + + + + + + +