Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
W
whoscall
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Wei Han
whoscall
Commits
94dd73a3
Commit
94dd73a3
authored
Sep 10, 2025
by
alep
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update on android
parent
b72847bb
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
273 additions
and
52 deletions
+273
-52
App.tsx
App.tsx
+6
-2
IncomingCallService.kt
...oid/app/src/main/java/com/whoscall/IncomingCallService.kt
+47
-34
MainActivity.kt
android/app/src/main/java/com/whoscall/MainActivity.kt
+1
-1
StorageModule.kt
android/app/src/main/java/com/whoscall/StorageModule.kt
+73
-15
VIPListScreen.tsx
src/screens/VIPListScreen.tsx
+146
-0
No files found.
App.tsx
View file @
94dd73a3
...
@@ -17,8 +17,9 @@ import {
...
@@ -17,8 +17,9 @@ import {
import
{
check
,
request
,
PERMISSIONS
,
RESULTS
}
from
'react-native-permissions'
;
import
{
check
,
request
,
PERMISSIONS
,
RESULTS
}
from
'react-native-permissions'
;
// import AsyncStorage from '@react-native-async-storage/async-storage';
// import AsyncStorage from '@react-native-async-storage/async-storage';
import
DeviceInfo
from
'react-native-device-info'
;
import
DeviceInfo
from
'react-native-device-info'
;
import
VIPListScreen
from
'./src/screens/VIPListScreen'
;
const
{
ContactModule
,
VIPStorage
,
OverlayPermission
}
=
NativeModules
as
any
;
const
{
ContactModule
,
VIPStorage
}
=
NativeModules
as
any
;
const
eventEmitter
=
new
NativeEventEmitter
(
ContactModule
);
const
eventEmitter
=
new
NativeEventEmitter
(
ContactModule
);
type
Contact
=
{
name
:
string
;
number
:
string
};
type
Contact
=
{
name
:
string
;
number
:
string
};
...
@@ -290,7 +291,9 @@ const checkAllPermissions = useCallback(async () => {
...
@@ -290,7 +291,9 @@ const checkAllPermissions = useCallback(async () => {
return
(
return
(
<
View
style=
{
styles
.
container
}
>
<
View
style=
{
styles
.
container
}
>
<
Text
style=
{
styles
.
title
}
>
Permissions Status
</
Text
>
<
Text
style=
{
{
color
:
"#000000"
}
}
>
Permissions Status
</
Text
>
{
(
permissionsList
??
[]).
map
(
perm
=>
(
{
(
permissionsList
??
[]).
map
(
perm
=>
(
<
View
key=
{
perm
.
key
}
style=
{
styles
.
row
}
>
<
View
key=
{
perm
.
key
}
style=
{
styles
.
row
}
>
<
Text
style=
{
styles
.
text
}
>
{
perm
.
key
}
</
Text
>
<
Text
style=
{
styles
.
text
}
>
{
perm
.
key
}
</
Text
>
...
@@ -310,6 +313,7 @@ const checkAllPermissions = useCallback(async () => {
...
@@ -310,6 +313,7 @@ const checkAllPermissions = useCallback(async () => {
<
Button
title=
"Reload Call Directory"
onPress=
{
reloadCallDirectory
}
/>
<
Button
title=
"Reload Call Directory"
onPress=
{
reloadCallDirectory
}
/>
</>
</>
)
}
)
}
<
VIPListScreen
/>
</
View
>
</
View
>
);
);
}
}
...
...
android/app/src/main/java/com/whoscall/IncomingCallService.kt
View file @
94dd73a3
...
@@ -6,43 +6,56 @@ import android.telecom.CallScreeningService
...
@@ -6,43 +6,56 @@ import android.telecom.CallScreeningService
import
android.telecom.Call.Details
import
android.telecom.Call.Details
import
androidx.annotation.RequiresApi
import
androidx.annotation.RequiresApi
import
android.util.Log
import
android.util.Log
import
android.content.Context
import
org.json.JSONObject
import
android.content.SharedPreferences
@RequiresApi
(
Build
.
VERSION_CODES
.
N
)
@RequiresApi
(
Build
.
VERSION_CODES
.
N
)
class
IncomingCallService
:
CallScreeningService
()
{
class
IncomingCallService
:
CallScreeningService
()
{
override
fun
onScreenCall
(
callDetails
:
Details
)
{
private
fun
normalize
(
num
:
String
?):
String
{
if
(
Build
.
VERSION
.
SDK_INT
>=
Build
.
VERSION_CODES
.
Q
)
{
if
(
num
.
isNullOrBlank
())
return
""
if
(
callDetails
.
callDirection
!=
Details
.
DIRECTION_INCOMING
)
{
val
digits
=
num
.
filter
{
it
.
isDigit
()
}
// Not an incoming call, ignore
if
(
digits
.
isEmpty
())
return
""
return
return
if
(
digits
.
startsWith
(
"0"
))
"60"
+
digits
.
drop
(
1
)
else
digits
}
}
}
val
number
=
callDetails
.
handle
?.
schemeSpecificPart
?:
"Unknown"
private
fun
loadVIPMap
():
org
.
json
.
JSONObject
{
val
prefs
=
getSharedPreferences
(
"WhoscallPrefs"
,
MODE_PRIVATE
)
// Lookup VIPs
val
json
=
prefs
.
getString
(
"vip_map"
,
"{}"
)
?:
"{}"
// val prefs = getSharedPreferences("WhoscallPrefs", MODE_PRIVATE)
return
try
{
org
.
json
.
JSONObject
(
json
)
}
catch
(
_
:
Exception
)
{
org
.
json
.
JSONObject
()
}
val
vipNumber
=
"+60164034061"
}
val
vipName
=
"Alyp"
override
fun
onScreenCall
(
callDetails
:
Details
)
{
Log
.
d
(
"IncomingCallReceiver"
,
"Looking up number: $number, Found VIPNumber: $vipNumber, Found VIPName: $vipName"
)
if
(
Build
.
VERSION
.
SDK_INT
>=
Build
.
VERSION_CODES
.
Q
)
{
// Only show overlay if VIP
if
(
callDetails
.
callDirection
!=
Details
.
DIRECTION_INCOMING
)
return
if
(!
vipNumber
.
isNullOrEmpty
()
&&
number
.
contains
(
vipNumber
))
{
}
val
overlayIntent
=
Intent
(
this
,
OverlayService
::
class
.
java
).
apply
{
putExtra
(
"number"
,
number
)
val
raw
=
callDetails
.
handle
?.
schemeSpecificPart
?:
""
putExtra
(
"displayName"
,
vipName
)
val
incoming
=
normalize
(
raw
)
}
startService
(
overlayIntent
)
val
vipMap
=
loadVIPMap
()
}
else
{
var
matchedName
:
String
?
=
null
val
overlayIntent
=
Intent
(
this
,
OverlayService
::
class
.
java
).
apply
{
putExtra
(
"number"
,
number
)
vipMap
.
keys
().
forEach
{
saved
->
putExtra
(
"displayName"
,
"Unknown Caller"
)
if
(
incoming
.
endsWith
(
saved
)
||
saved
.
endsWith
(
incoming
))
{
}
matchedName
=
vipMap
.
optString
(
saved
,
null
)
startService
(
overlayIntent
)
return
@forEach
}
}
}
// Let the call through without blocking
respondToCall
(
callDetails
,
CallResponse
.
Builder
().
build
())
if
(
matchedName
!=
null
)
{
// ✅ VIP matched → show overlay
val
overlayIntent
=
Intent
(
this
,
OverlayService
::
class
.
java
).
apply
{
putExtra
(
"number"
,
raw
.
ifBlank
{
"Unknown"
})
putExtra
(
"displayName"
,
matchedName
)
}
startService
(
overlayIntent
)
}
else
{
// 🚫 Not VIP → do nothing (no overlay)
android
.
util
.
Log
.
d
(
"IncomingCallService"
,
"No VIP match for '$raw' (norm='$incoming'). Skipping overlay."
)
}
}
// Always let the call through
respondToCall
(
callDetails
,
CallResponse
.
Builder
().
build
())
}
}
}
android/app/src/main/java/com/whoscall/MainActivity.kt
View file @
94dd73a3
...
@@ -55,7 +55,7 @@ class MainActivity : ReactActivity() {
...
@@ -55,7 +55,7 @@ class MainActivity : ReactActivity() {
}
}
private
fun
simulateVipCall
()
{
private
fun
simulateVipCall
()
{
val
vipNumber
=
"+601
89884118
"
val
vipNumber
=
"+601
64034061
"
val
vipName
=
"Wei Yi"
val
vipName
=
"Wei Yi"
val
prefs
=
getSharedPreferences
(
"WhoscallPrefs"
,
MODE_PRIVATE
)
val
prefs
=
getSharedPreferences
(
"WhoscallPrefs"
,
MODE_PRIVATE
)
...
...
android/app/src/main/java/com/whoscall/StorageModule.kt
View file @
94dd73a3
package
com.whoscall
package
com.whoscall
import
android.content.Context
import
android.content.Context
import
android.content.Intent
import
android.util.Log
import
android.util.Log
import
com.facebook.react.bridge.*
import
com.facebook.react.bridge.*
import
android.content.SharedPreferences
import
org.json.JSONObject
class
StorageModule
(
reactContext
:
ReactApplicationContext
)
:
class
StorageModule
(
reactContext
:
ReactApplicationContext
)
:
ReactContextBaseJavaModule
(
reactContext
)
{
ReactContextBaseJavaModule
(
reactContext
)
{
private
val
prefs
=
reactContext
.
getSharedPreferences
(
"WhoscallPrefs"
,
Context
.
MODE_PRIVATE
)
private
val
prefs
=
reactContext
.
getSharedPreferences
(
"WhoscallPrefs"
,
Context
.
MODE_PRIVATE
)
private
val
VIP_KEY
=
"vip_map"
override
fun
getName
():
String
{
override
fun
getName
()
=
"VIPStorage"
return
"VIPStorage"
// --- utils ---
private
fun
normalize
(
num
:
String
?):
String
{
if
(
num
.
isNullOrBlank
())
return
""
val
digits
=
num
.
filter
{
it
.
isDigit
()
}
if
(
digits
.
isEmpty
())
return
""
return
if
(
digits
.
startsWith
(
"0"
))
"60"
+
digits
.
drop
(
1
)
else
digits
}
private
fun
readMap
():
JSONObject
{
val
json
=
prefs
.
getString
(
VIP_KEY
,
"{}"
)
?:
"{}"
return
try
{
JSONObject
(
json
)
}
catch
(
_
:
Exception
)
{
JSONObject
()
}
}
private
fun
writeMap
(
obj
:
JSONObject
)
{
prefs
.
edit
().
putString
(
VIP_KEY
,
obj
.
toString
()).
apply
()
}
// --- API: add/update a VIP ---
@ReactMethod
fun
saveVIP
(
name
:
String
,
number
:
String
,
promise
:
Promise
)
{
try
{
val
key
=
normalize
(
number
)
val
map
=
readMap
()
map
.
put
(
key
,
name
)
writeMap
(
map
)
Log
.
d
(
"VIPStorage"
,
"Saved VIP $name -> $key"
)
promise
.
resolve
(
true
)
}
catch
(
e
:
Exception
)
{
promise
.
reject
(
"SAVE_ERROR"
,
e
)
}
}
}
@ReactMethod
// --- API: remove a VIP ---
fun
saveVIP
(
name
:
String
,
number
:
String
,
promise
:
Promise
)
{
@ReactMethod
try
{
fun
removeVIP
(
number
:
String
,
promise
:
Promise
)
{
prefs
.
edit
().
putString
(
number
,
name
).
apply
()
try
{
Log
.
d
(
"VIPStorage"
,
"Saved VIP $name -> $number"
)
val
key
=
normalize
(
number
)
promise
.
resolve
(
true
)
val
map
=
readMap
()
}
catch
(
e
:
Exception
)
{
map
.
remove
(
key
)
promise
.
reject
(
"SAVE_ERROR"
,
e
)
writeMap
(
map
)
}
promise
.
resolve
(
true
)
}
catch
(
e
:
Exception
)
{
promise
.
reject
(
"REMOVE_ERROR"
,
e
)
}
}
}
// --- API: list all VIPs (as array of {name, number}) ---
@ReactMethod
fun
listVIPs
(
promise
:
Promise
)
{
try
{
val
map
=
readMap
()
val
arr
=
Arguments
.
createArray
()
map
.
keys
().
forEach
{
k
->
val
item
=
Arguments
.
createMap
()
item
.
putString
(
"number"
,
k
)
item
.
putString
(
"name"
,
map
.
getString
(
k
))
arr
.
pushMap
(
item
)
}
promise
.
resolve
(
arr
)
}
catch
(
e
:
Exception
)
{
promise
.
reject
(
"LIST_ERROR"
,
e
)
}
}
// --- Optional: clear all ---
@ReactMethod
fun
clearVIPs
(
promise
:
Promise
)
{
writeMap
(
JSONObject
())
promise
.
resolve
(
true
)
}
}
}
src/screens/VIPListScreen.tsx
0 → 100644
View file @
94dd73a3
import
React
from
'react'
;
import
{
View
,
Text
,
TextInput
,
TouchableOpacity
,
FlatList
,
StyleSheet
,
Alert
,
NativeModules
,
Platform
}
from
'react-native'
;
import
AsyncStorage
from
'@react-native-async-storage/async-storage'
;
type
VIP
=
{
name
:
string
;
number
:
string
};
// number should be normalized before save
const
{
VIPStorage
}
=
NativeModules
as
any
;
const
ASYNC_KEY
=
'vip_list_array_v1'
;
function
normalize
(
num
:
string
)
{
const
digits
=
(
num
||
''
).
replace
(
/
\D
+/g
,
''
);
if
(
!
digits
)
return
''
;
return
digits
.
startsWith
(
'0'
)
?
'60'
+
digits
.
slice
(
1
)
:
digits
;
}
type
State
=
{
list
:
VIP
[];
name
:
string
;
number
:
string
;
loading
:
boolean
;
};
export
default
class
VIPListScreen
extends
React
.
PureComponent
<
{},
State
>
{
state
:
State
=
{
list
:
[],
name
:
''
,
number
:
''
,
loading
:
true
};
async
componentDidMount
()
{
await
this
.
load
();
}
// --- load from AsyncStorage AND reconcile with native
load
=
async
()
=>
{
try
{
const
raw
=
await
AsyncStorage
.
getItem
(
ASYNC_KEY
);
const
list
:
VIP
[]
=
raw
?
JSON
.
parse
(
raw
)
:
[];
this
.
setState
({
list
,
loading
:
false
});
// optional one-way sync to native on mount
for
(
const
item
of
list
)
{
await
VIPStorage
?.
saveVIP
(
item
.
name
,
item
.
number
);
}
}
catch
(
e
)
{
console
.
warn
(
'Failed to load VIP list'
,
e
);
this
.
setState
({
loading
:
false
});
}
};
saveAll
=
async
(
list
:
VIP
[])
=>
{
await
AsyncStorage
.
setItem
(
ASYNC_KEY
,
JSON
.
stringify
(
list
));
};
add
=
async
()
=>
{
const
{
name
,
number
,
list
}
=
this
.
state
;
const
n
=
normalize
(
number
);
if
(
!
name
.
trim
()
||
!
n
)
{
Alert
.
alert
(
'Invalid'
,
'Please enter a name and a valid phone number.'
);
return
;
}
// prevent duplicates
if
(
list
.
some
(
v
=>
v
.
number
===
n
))
{
Alert
.
alert
(
'Exists'
,
'This number is already in your VIP list.'
);
return
;
}
const
next
=
[...
list
,
{
name
:
name
.
trim
(),
number
:
n
}];
this
.
setState
({
list
:
next
,
name
:
''
,
number
:
''
});
await
this
.
saveAll
(
next
);
// mirror to native
await
VIPStorage
?.
saveVIP
(
name
.
trim
(),
n
);
};
remove
=
async
(
number
:
string
)
=>
{
const
next
=
this
.
state
.
list
.
filter
(
v
=>
v
.
number
!==
number
);
this
.
setState
({
list
:
next
});
await
this
.
saveAll
(
next
);
// mirror to native
await
VIPStorage
?.
removeVIP
(
number
);
};
renderItem
=
({
item
}:
{
item
:
VIP
})
=>
(
<
View
style=
{
styles
.
item
}
>
<
View
style=
{
{
flex
:
1
}
}
>
<
Text
style=
{
styles
.
name
}
>
{
item
.
name
}
</
Text
>
<
Text
style=
{
styles
.
number
}
>
+
{
item
.
number
.
startsWith
(
'60'
)
?
item
.
number
:
item
.
number
}
</
Text
>
</
View
>
<
TouchableOpacity
style=
{
styles
.
removeBtn
}
onPress=
{
()
=>
this
.
remove
(
item
.
number
)
}
>
<
Text
style=
{
styles
.
removeText
}
>
Remove
</
Text
>
</
TouchableOpacity
>
</
View
>
);
render
()
{
const
{
list
,
name
,
number
,
loading
}
=
this
.
state
;
return
(
<
View
style=
{
styles
.
container
}
>
<
Text
style=
{
styles
.
title
}
>
VIP List
</
Text
>
<
View
style=
{
styles
.
row
}
>
<
TextInput
placeholderTextColor=
{
"#000000"
}
value=
{
name
}
onChangeText=
{
t
=>
this
.
setState
({
name
:
t
})
}
style=
{
styles
.
input
}
placeholder=
"Name (e.g., Alyp)"
/>
<
TextInput
placeholderTextColor=
{
"#000000"
}
value=
{
number
}
onChangeText=
{
t
=>
this
.
setState
({
number
:
t
})
}
style=
{
styles
.
input
}
keyboardType=
"phone-pad"
placeholder=
"Phone (e.g., +6012-3456789)"
/>
</
View
>
<
TouchableOpacity
style=
{
styles
.
addBtn
}
onPress=
{
this
.
add
}
>
<
Text
style=
{
styles
.
addText
}
>
Add VIP
</
Text
>
</
TouchableOpacity
>
{
loading
?
(
<
Text
style=
{
{
textAlign
:
'center'
,
marginTop
:
20
}
}
>
Loading…
</
Text
>
)
:
(
<
FlatList
data=
{
list
}
keyExtractor=
{
(
it
)
=>
it
.
number
}
renderItem=
{
this
.
renderItem
}
ListEmptyComponent=
{
<
Text
style=
{
{
textAlign
:
'center'
,
marginTop
:
20
}
}
>
No VIPs yet.
</
Text
>
}
/>
)
}
</
View
>
);
}
}
const
styles
=
StyleSheet
.
create
({
container
:
{
flex
:
1
,
padding
:
16
,
backgroundColor
:
'white'
},
title
:
{
fontSize
:
22
,
fontWeight
:
'600'
,
marginBottom
:
12
,
textAlign
:
'center'
},
row
:
{
flexDirection
:
'row'
,
gap
:
8
},
input
:
{
flex
:
1
,
borderWidth
:
1
,
borderColor
:
'#ddd'
,
borderRadius
:
8
,
padding
:
12
,
color
:
"#000000"
},
addBtn
:
{
backgroundColor
:
'#0a7'
,
padding
:
12
,
borderRadius
:
8
,
marginVertical
:
12
,
alignItems
:
'center'
},
addText
:
{
color
:
'white'
,
fontWeight
:
'600'
},
item
:
{
flexDirection
:
'row'
,
alignItems
:
'center'
,
paddingVertical
:
12
,
borderBottomWidth
:
1
,
borderColor
:
'#eee'
},
name
:
{
fontSize
:
16
,
fontWeight
:
'600'
},
number
:
{
color
:
'#666'
,
marginTop
:
4
},
removeBtn
:
{
paddingVertical
:
6
,
paddingHorizontal
:
12
,
backgroundColor
:
'#d33'
,
borderRadius
:
6
},
removeText
:
{
color
:
'white'
,
fontWeight
:
'600'
},
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment