Skip to content

Commit b5201f6

Browse files
committed
Add ability to detect missed calls on the Android App
1 parent 448d216 commit b5201f6

File tree

7 files changed

+226
-39
lines changed

7 files changed

+226
-39
lines changed

android/app/src/main/java/com/httpsms/HttpSmsApiService.kt

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.httpsms
22

33
import android.content.Context
4-
import android.os.BatteryManager
54
import okhttp3.MediaType.Companion.toMediaType
65
import okhttp3.OkHttpClient
76
import okhttp3.Request
@@ -10,8 +9,6 @@ import org.apache.commons.text.StringEscapeUtils
109
import timber.log.Timber
1110
import java.net.URI
1211
import java.net.URL
13-
import java.time.ZonedDateTime
14-
import java.time.format.DateTimeFormatter
1512
import java.util.logging.Level
1613
import java.util.logging.Logger.getLogger
1714

@@ -100,7 +97,36 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
10097
val message = ResponseMessage.fromJson(response.body!!.string())
10198
response.close()
10299
Timber.i("received message stored successfully for message with ID [${message?.data?.id}]" )
103-
return true;
100+
return true
101+
}
102+
103+
fun sendMissedCallEvent(sim: String, from: String, to: String, timestamp: String): Boolean {
104+
val body = """
105+
{
106+
"sim": "$sim",
107+
"from": "$from",
108+
"timestamp": "$timestamp",
109+
"to": "$to"
110+
}
111+
""".trimIndent()
112+
113+
val request: Request = Request.Builder()
114+
.url(resolveURL("/v1/calls/missed"))
115+
.post(body.toRequestBody(jsonMediaType))
116+
.header(apiKeyHeader, apiKey)
117+
.header(clientVersionHeader, BuildConfig.VERSION_NAME)
118+
.build()
119+
120+
val response = client.newCall(request).execute()
121+
if (!response.isSuccessful) {
122+
Timber.e("error response [${response.body?.string()}] with code [${response.code}] while sending missed call event [${body}]")
123+
response.close()
124+
return false
125+
}
126+
127+
response.close()
128+
Timber.i("missed call from [${from}] to [${to}] sent successfully with timestamp [${timestamp}]" )
129+
return true
104130
}
105131

106132
fun storeHeartbeat(phoneNumber: String, charging: Boolean) {

android/app/src/main/java/com/httpsms/MainActivity.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ class MainActivity : AppCompatActivity() {
121121
permissions += Manifest.permission.POST_NOTIFICATIONS
122122
}
123123

124+
if(Settings.isIncomingCallEventsEnabled(context,Constants.SIM1) || Settings.isIncomingCallEventsEnabled(context,Constants.SIM2) ) {
125+
permissions += Manifest.permission.READ_CALL_LOG
126+
permissions += Manifest.permission.READ_PHONE_STATE
127+
}
128+
124129
requestPermissionLauncher.launch(permissions)
125130

126131
Timber.d("creating permissions launcher")

android/app/src/main/java/com/httpsms/Settings.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ object Settings {
2828
private const val SETTINGS_ENCRYPTION_KEY = "SETTINGS_ENCRYPTION_KEY"
2929
private const val SETTINGS_ENCRYPT_RECEIVED_MESSAGES = "SETTINGS_ENCRYPT_RECEIVED_MESSAGES"
3030

31+
fun getPhoneNumber(context:Context, sim: String): String {
32+
if (sim == Constants.SIM2) {
33+
return getSIM2PhoneNumber(context)
34+
}
35+
return getSIM1PhoneNumber(context)
36+
}
37+
3138
fun getSIM1PhoneNumber(context: Context): String {
3239
Timber.d(Settings::getSIM1PhoneNumber.name)
3340

android/app/src/main/java/com/httpsms/SettingsActivity.kt

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
package com.httpsms
22

3-
import android.Manifest
43
import android.content.Context
54
import android.content.Intent
6-
import android.os.Build
7-
import android.os.Build.VERSION_CODES
85
import android.os.Bundle
9-
import androidx.activity.result.contract.ActivityResultContracts
106
import androidx.appcompat.app.AppCompatActivity
11-
import androidx.core.content.ContextCompat
127
import androidx.core.widget.doAfterTextChanged
138
import com.google.android.material.appbar.MaterialToolbar
149
import com.google.android.material.button.MaterialButton
@@ -68,15 +63,25 @@ class SettingsActivity : AppCompatActivity() {
6863
sim2OutgoingMessages.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setActiveStatusAsync(context, isChecked, Constants.SIM2) } }
6964

7065
handleEncryptionSettings(context)
71-
handleIncomingCallEventsSim1(context)
66+
handleIncomingCallEvents(context)
7267
}
7368

74-
private fun handleIncomingCallEventsSim1(context: Context) {
69+
private fun handleIncomingCallEvents(context: Context) {
7570
val enableIncomingCallEvents = findViewById<SwitchMaterial>(R.id.settingsSim1EnableIncomingCallEvents)
7671
enableIncomingCallEvents.isChecked = Settings.isIncomingCallEventsEnabled(context, Constants.SIM1)
7772
enableIncomingCallEvents.setOnCheckedChangeListener{ _, isChecked -> run {
78-
requestReadCallLogPermission(context)
79-
Timber.d("how are you [${isChecked}]")
73+
Settings.setIncomingCallEventsEnabled(context, Constants.SIM1, isChecked)
74+
}}
75+
76+
val sim2IncomingCalls = findViewById<SwitchMaterial>(R.id.settingsSim2EnableIncomingCallEvents)
77+
if (!Settings.isDualSIM(context)) {
78+
sim2IncomingCalls.visibility = SwitchMaterial.GONE
79+
return
80+
}
81+
82+
sim2IncomingCalls.isChecked = Settings.isIncomingCallEventsEnabled(context, Constants.SIM2)
83+
sim2IncomingCalls.setOnCheckedChangeListener{ _, isChecked -> run {
84+
Settings.setIncomingCallEventsEnabled(context, Constants.SIM2, isChecked)
8085
}}
8186
}
8287

@@ -129,25 +134,6 @@ class SettingsActivity : AppCompatActivity() {
129134
return findViewById(R.id.settings_toolbar)
130135
}
131136

132-
private fun requestReadCallLogPermission(context: Context) {
133-
if(!Settings.isLoggedIn(context)) {
134-
return
135-
}
136-
Timber.d("requesting permissions")
137-
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
138-
permissions.entries.forEach {
139-
Timber.d("${it.key} = ${it.value}")
140-
}
141-
}
142-
val permissions = arrayOf(
143-
Manifest.permission.READ_CALL_LOG,
144-
Manifest.permission.READ_PHONE_STATE
145-
)
146-
147-
requestPermissionLauncher.launch(permissions)
148-
Timber.d("creating permissions launcher")
149-
}
150-
151137
private fun onLogoutClick() {
152138
Timber.d("logout button clicked")
153139
MaterialAlertDialogBuilder(this)
@@ -167,6 +153,8 @@ class SettingsActivity : AppCompatActivity() {
167153
Settings.setEncryptionKey(this, null)
168154
Settings.setEncryptReceivedMessages(this, false)
169155
Settings.setFcmTokenLastUpdateTimestampAsync(this, 0)
156+
Settings.setIncomingCallEventsEnabled(this, Constants.SIM1, false)
157+
Settings.setIncomingCallEventsEnabled(this, Constants.SIM2, false)
170158
redirectToLogin()
171159
}
172160
.show()
Lines changed: 156 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,174 @@
11
package com.httpsms.receivers
22

3+
import android.annotation.SuppressLint
34
import android.content.BroadcastReceiver
45
import android.content.Context
56
import android.content.Intent
7+
import android.os.Build
8+
import android.provider.CallLog
9+
import android.telephony.SubscriptionManager
610
import android.telephony.TelephonyManager
11+
import androidx.work.Constraints
12+
import androidx.work.Data
13+
import androidx.work.NetworkType
14+
import androidx.work.OneTimeWorkRequest
15+
import androidx.work.WorkManager
16+
import androidx.work.Worker
17+
import androidx.work.WorkerParameters
18+
import androidx.work.workDataOf
19+
import com.httpsms.Constants
20+
import com.httpsms.HttpSmsApiService
21+
import com.httpsms.Settings
722
import timber.log.Timber
23+
import java.time.Instant
24+
import java.time.ZoneOffset
25+
import java.time.ZonedDateTime
26+
import java.time.format.DateTimeFormatter
827

928

1029
class PhoneStateReceiver : BroadcastReceiver() {
1130
override fun onReceive(context: Context, intent: Intent) {
1231
Timber.d("onReceive: ${intent.action}")
1332
val stateStr = intent.extras!!.getString(TelephonyManager.EXTRA_STATE)
14-
val subscriptionId = intent.extras!!.getString(TelephonyManager.EXTRA_SUBSCRIPTION_ID)
33+
34+
@Suppress("DEPRECATION")
1535
val number = intent.extras!!.getString(TelephonyManager.EXTRA_INCOMING_NUMBER)
16-
Timber.w("state = [${stateStr}] number = [${number}], subscriptionID = [${subscriptionId}]")
17-
val bundle = intent.extras
18-
if (bundle != null) {
19-
for (key in bundle.keySet()) {
20-
Timber.w(key + " : " + if (bundle[key] != null) bundle[key] else "NULL")
36+
if (stateStr != "IDLE" || number == null) {
37+
Timber.d("event is not a missed call or permission is not granted state = [${stateStr}]")
38+
return
39+
}
40+
41+
// Sleep so that the call gets added into the call log
42+
Thread.sleep(200)
43+
44+
val lastCall = getCallLog(context, number)
45+
if (lastCall == null) {
46+
Timber.d("The call from [${number}] was not a missed call.")
47+
return
48+
}
49+
50+
handleMissedCallEvent(context, number, lastCall)
51+
}
52+
53+
private fun handleMissedCallEvent(context: Context, contact: String, callLog: Pair<ZonedDateTime, String>) {
54+
val (timestamp, sim) = callLog
55+
val owner = Settings.getPhoneNumber(context, sim)
56+
57+
if (!Settings.isLoggedIn(context)) {
58+
Timber.w("[${sim}] user is not logged in")
59+
return
60+
}
61+
62+
if (!Settings.isIncomingCallEventsEnabled(context, callLog.second)) {
63+
Timber.w("[${sim}] incoming call events is not enabled")
64+
return
65+
}
66+
67+
val constraints = Constraints.Builder()
68+
.setRequiredNetworkType(NetworkType.CONNECTED)
69+
.build()
70+
71+
val inputData: Data = workDataOf(
72+
Constants.KEY_MESSAGE_FROM to contact,
73+
Constants.KEY_MESSAGE_SIM to sim,
74+
Constants.KEY_MESSAGE_TO to owner,
75+
Constants.KEY_MESSAGE_TIMESTAMP to DateTimeFormatter.ofPattern(Constants.TIMESTAMP_PATTERN).format(timestamp).replace("+", "Z")
76+
)
77+
78+
val work = OneTimeWorkRequest
79+
.Builder(MissedCallWorker::class.java)
80+
.setConstraints(constraints)
81+
.setInputData(inputData)
82+
.build()
83+
84+
WorkManager
85+
.getInstance(context)
86+
.enqueue(work)
87+
88+
Timber.d("work enqueued with ID [${work.id}] for missed phone call from [${contact}] to [${owner}] in [${sim}]")
89+
}
90+
91+
@SuppressLint("MissingPermission")
92+
private fun getSlotIndexFromSubscriptionId(context: Context, subscriptionId: Int): String {
93+
val localSubscriptionManager: SubscriptionManager = if (Build.VERSION.SDK_INT < 31) {
94+
@Suppress("DEPRECATION")
95+
SubscriptionManager.from(context)
96+
} else {
97+
context.getSystemService(SubscriptionManager::class.java)
98+
}
99+
100+
var sim = Constants.SIM1
101+
localSubscriptionManager.activeSubscriptionInfoList.forEach {
102+
if (it.subscriptionId == subscriptionId) {
103+
if (it.simSlotIndex > 0){
104+
sim = Constants.SIM2
105+
}
21106
}
22107
}
108+
return sim
109+
}
110+
111+
private fun getCallLog(context: Context, phoneNumber: String): Pair<ZonedDateTime, String>? {
112+
// Specify the columns you want to retrieve from the call log
113+
val projection = arrayOf(CallLog.Calls.NUMBER, CallLog.Calls.DATE, CallLog.Calls.TYPE, CallLog.Calls.PHONE_ACCOUNT_ID)
114+
115+
// Query the call log content provider
116+
val cursor = context.contentResolver.query(
117+
CallLog.Calls.CONTENT_URI,
118+
projection,
119+
null,
120+
null,
121+
CallLog.Calls.DATE + " DESC" // Order by date in descending order
122+
)
123+
124+
// Check if the cursor is not null and contains at least one entry
125+
if (cursor != null && cursor.moveToFirst()) {
126+
val number = cursor.getString(cursor.getColumnIndexOrThrow(CallLog.Calls.NUMBER))
127+
if (number != phoneNumber) {
128+
Timber.w("last phone call has phone number [${number}] but the expected phone number was [${phoneNumber}]")
129+
return null
130+
}
131+
132+
if (cursor.getInt(cursor.getColumnIndexOrThrow(CallLog.Calls.TYPE)) != CallLog.Calls.MISSED_TYPE) {
133+
Timber.w("last phone call from phone number was [${phoneNumber}] was not a missed call")
134+
return null
135+
}
136+
137+
val date = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DATE))
138+
val sim = getSlotIndexFromSubscriptionId(context, cursor.getInt(cursor.getColumnIndexOrThrow(CallLog.Calls.PHONE_ACCOUNT_ID)))
139+
140+
// Convert date to a readable format (optional)
141+
val dateString = java.text.DateFormat.getDateTimeInstance().format(date)
142+
Timber.d("missed call date is [${dateString}], SIM = [${sim}]")
143+
144+
// Close the cursor to free up resources
145+
cursor.close()
146+
147+
// Construct a string representing the last call
148+
return Pair(ZonedDateTime.ofInstant(Instant.ofEpochMilli(date), ZoneOffset.UTC), sim)
149+
}
150+
151+
// Close the cursor if it's not null even if it doesn't contain any data
152+
cursor?.close()
153+
154+
// Return null if no calls are found
155+
return null
156+
}
157+
158+
internal class MissedCallWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) {
159+
override fun doWork(): Result {
160+
Timber.i("[${this.inputData.getString(Constants.KEY_MESSAGE_SIM)}] forwarding missed call from [${this.inputData.getString(Constants.KEY_MESSAGE_FROM)}] to [${this.inputData.getString(Constants.KEY_MESSAGE_TO)}]")
161+
162+
if (HttpSmsApiService.create(applicationContext).sendMissedCallEvent(
163+
this.inputData.getString(Constants.KEY_MESSAGE_SIM)!!,
164+
this.inputData.getString(Constants.KEY_MESSAGE_FROM)!!,
165+
this.inputData.getString(Constants.KEY_MESSAGE_TO)!!,
166+
this.inputData.getString(Constants.KEY_MESSAGE_TIMESTAMP)!!,
167+
)) {
168+
return Result.success()
169+
}
170+
171+
return Result.retry()
172+
}
23173
}
24174
}

android/app/src/main/res/layout/activity_settings.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,16 @@
126126
android:textSize="18sp"
127127
tools:ignore="TouchTargetSizeCheck" />
128128

129+
<com.google.android.material.switchmaterial.SwitchMaterial
130+
android:id="@+id/settingsSim2EnableIncomingCallEvents"
131+
android:layout_width="match_parent"
132+
android:layout_height="wrap_content"
133+
android:layout_marginBottom="16dp"
134+
android:minHeight="48dp"
135+
android:text="@string/enable_sim2_incoming_call_events"
136+
android:textSize="18sp"
137+
tools:ignore="TouchTargetSizeCheck" />
138+
129139
<com.google.android.material.textfield.TextInputLayout
130140
android:id="@+id/settingsEncryptionKeyLayout"
131141
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"

android/app/src/main/res/values/strings.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@
2929
<string name="encryption_key">Encryption Key</string>
3030
<string name="encrypt_received_messages">Encrypt received messages</string>
3131
<string name="enable_debug_logs">Enable debug logs</string>
32-
<string name="enable_incoming_call_events_sim1">Enable SIM1 incoming call events</string>
32+
<string name="enable_incoming_call_events_sim1">Enable SIM1 missed call events</string>
33+
<string name="enable_sim2_incoming_call_events">Enable SIM2 missed call events</string>
3334
</resources>

0 commit comments

Comments
 (0)