Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
<package android:name="org.fossify.contacts.debug" />
<package android:name="org.fossify.contacts" />

<!-- Messenger quick-actions on the ViewContact screen check whether
these are installed before showing the corresponding menu item. -->
<package android:name="com.whatsapp" />
<package android:name="com.whatsapp.w4b" />
<package android:name="org.telegram.messenger" />
<package android:name="org.telegram.messenger.web" />
<package android:name="org.thoughtcrime.securesms" />

<!-- Required for loading contact accounts -->
<intent>
<action android:name="android.accounts.AccountAuthenticator" />
Expand All @@ -35,7 +43,8 @@

<application
android:name="org.fossify.commons.FossifyApp"
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:appCategory="social"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_launcher_name"
Expand Down Expand Up @@ -223,7 +232,8 @@

<receiver
android:name=".receivers.BootCompletedReceiver"
android:exported="true">
android:exported="true"
android:permission="android.permission.RECEIVE_BOOT_COMPLETED">

<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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() {
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ class ViewContactActivity : ContactActivity() {
}

setupPhoneNumbers()
renderConnectedApps()
setupEmails()
setupAddresses()
setupIMs()
Expand Down Expand Up @@ -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<android.widget.ImageView>(R.id.dsremo_messenger_icon)
val label = row.findViewById<org.fossify.commons.views.MyTextView>(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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MessengerAction> = 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<MessengerAction> {
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
}
}
31 changes: 30 additions & 1 deletion app/src/main/res/layout/activity_view_contact.xml
Original file line number Diff line number Diff line change
Expand Up @@ -278,11 +278,40 @@
android:orientation="vertical"
android:visibility="gone" />

<!-- dsremo: Google Contacts-style "Connected apps" section.
Populated by ViewContactActivity from the set of installed
messengers that support phone-number-based intents (WhatsApp,
WhatsApp Business, Telegram, Signal, Viber, Skype, ...). Each
row uses the real app icon (via PackageManager) and a Google-
style "Message on WhatsApp" / "Voice call on WhatsApp" label. -->
<ImageView
android:id="@+id/dsremo_connected_apps_image"
android:layout_width="@dimen/contact_icons_size"
android:layout_height="@dimen/contact_icons_size"
android:layout_below="@+id/contact_numbers_holder"
android:layout_alignParentStart="true"
android:layout_marginStart="@dimen/normal_margin"
android:contentDescription="@null"
android:paddingTop="@dimen/medium_margin"
android:paddingEnd="@dimen/small_margin"
android:paddingBottom="@dimen/small_margin"
android:src="@drawable/ic_im"
android:visibility="gone" />

<LinearLayout
android:id="@+id/contact_emails_holder"
android:id="@+id/dsremo_connected_apps_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/contact_numbers_holder"
android:layout_toEndOf="@+id/dsremo_connected_apps_image"
android:orientation="vertical"
android:visibility="gone" />

<LinearLayout
android:id="@+id/contact_emails_holder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/dsremo_connected_apps_holder"
android:layout_toEndOf="@+id/contact_name_image"
android:orientation="vertical"
android:visibility="gone" />
Expand Down
Loading
Loading