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
Show whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
252 additions
and
31 deletions
+252
-31
App.tsx
App.tsx
+6
-2
IncomingCallService.kt
...oid/app/src/main/java/com/whoscall/IncomingCallService.kt
+35
-22
MainActivity.kt
android/app/src/main/java/com/whoscall/MainActivity.kt
+1
-1
StorageModule.kt
android/app/src/main/java/com/whoscall/StorageModule.kt
+64
-6
VIPListScreen.tsx
src/screens/VIPListScreen.tsx
+146
-0
No files found.
App.tsx
View file @
94dd73a3
...
...
@@ -17,8 +17,9 @@ import {
import
{
check
,
request
,
PERMISSIONS
,
RESULTS
}
from
'react-native-permissions'
;
// import AsyncStorage from '@react-native-async-storage/async-storage';
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
);
type
Contact
=
{
name
:
string
;
number
:
string
};
...
...
@@ -290,7 +291,9 @@ const checkAllPermissions = useCallback(async () => {
return
(
<
View
style=
{
styles
.
container
}
>
<
Text
style=
{
styles
.
title
}
>
Permissions Status
</
Text
>
<
Text
style=
{
{
color
:
"#000000"
}
}
>
Permissions Status
</
Text
>
{
(
permissionsList
??
[]).
map
(
perm
=>
(
<
View
key=
{
perm
.
key
}
style=
{
styles
.
row
}
>
<
Text
style=
{
styles
.
text
}
>
{
perm
.
key
}
</
Text
>
...
...
@@ -310,6 +313,7 @@ const checkAllPermissions = useCallback(async () => {
<
Button
title=
"Reload Call Directory"
onPress=
{
reloadCallDirectory
}
/>
</>
)
}
<
VIPListScreen
/>
</
View
>
);
}
...
...
android/app/src/main/java/com/whoscall/IncomingCallService.kt
View file @
94dd73a3
...
...
@@ -6,43 +6,56 @@ import android.telecom.CallScreeningService
import
android.telecom.Call.Details
import
androidx.annotation.RequiresApi
import
android.util.Log
import
android.content.Context
import
android.content.SharedPreferences
import
org.json.JSONObject
@RequiresApi
(
Build
.
VERSION_CODES
.
N
)
class
IncomingCallService
:
CallScreeningService
()
{
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
loadVIPMap
():
org
.
json
.
JSONObject
{
val
prefs
=
getSharedPreferences
(
"WhoscallPrefs"
,
MODE_PRIVATE
)
val
json
=
prefs
.
getString
(
"vip_map"
,
"{}"
)
?:
"{}"
return
try
{
org
.
json
.
JSONObject
(
json
)
}
catch
(
_
:
Exception
)
{
org
.
json
.
JSONObject
()
}
}
override
fun
onScreenCall
(
callDetails
:
Details
)
{
if
(
Build
.
VERSION
.
SDK_INT
>=
Build
.
VERSION_CODES
.
Q
)
{
if
(
callDetails
.
callDirection
!=
Details
.
DIRECTION_INCOMING
)
{
// Not an incoming call, ignore
return
if
(
callDetails
.
callDirection
!=
Details
.
DIRECTION_INCOMING
)
return
}
}
val
number
=
callDetails
.
handle
?.
schemeSpecificPart
?:
"Unknown"
// Lookup VIPs
// val prefs = getSharedPreferences("WhoscallPrefs", MODE_PRIVATE)
val
vipNumber
=
"+60164034061"
val
vipName
=
"Alyp"
val
raw
=
callDetails
.
handle
?.
schemeSpecificPart
?:
""
val
incoming
=
normalize
(
raw
)
Log
.
d
(
"IncomingCallReceiver"
,
"Looking up number: $number, Found VIPNumber: $vipNumber, Found VIPName: $vipName"
)
// Only show overlay if VIP
if
(!
vipNumber
.
isNullOrEmpty
()
&&
number
.
contains
(
vipNumber
))
{
val
overlayIntent
=
Intent
(
this
,
OverlayService
::
class
.
java
).
apply
{
putExtra
(
"number"
,
number
)
putExtra
(
"displayName"
,
vipName
)
val
vipMap
=
loadVIPMap
()
var
matchedName
:
String
?
=
null
vipMap
.
keys
().
forEach
{
saved
->
if
(
incoming
.
endsWith
(
saved
)
||
saved
.
endsWith
(
incoming
))
{
matchedName
=
vipMap
.
optString
(
saved
,
null
)
return
@forEach
}
startService
(
overlayIntent
)
}
else
{
}
if
(
matchedName
!=
null
)
{
// ✅ VIP matched → show overlay
val
overlayIntent
=
Intent
(
this
,
OverlayService
::
class
.
java
).
apply
{
putExtra
(
"number"
,
number
)
putExtra
(
"displayName"
,
"Unknown Caller"
)
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."
)
}
// Let the call through without blocking
// 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() {
}
private
fun
simulateVipCall
()
{
val
vipNumber
=
"+601
89884118
"
val
vipNumber
=
"+601
64034061
"
val
vipName
=
"Wei Yi"
val
prefs
=
getSharedPreferences
(
"WhoscallPrefs"
,
MODE_PRIVATE
)
...
...
android/app/src/main/java/com/whoscall/StorageModule.kt
View file @
94dd73a3
package
com.whoscall
import
android.content.Context
import
android.content.Intent
import
android.util.Log
import
com.facebook.react.bridge.*
import
android.content.SharedPreferences
import
org.json.JSONObject
class
StorageModule
(
reactContext
:
ReactApplicationContext
)
:
ReactContextBaseJavaModule
(
reactContext
)
{
private
val
prefs
=
reactContext
.
getSharedPreferences
(
"WhoscallPrefs"
,
Context
.
MODE_PRIVATE
)
private
val
VIP_KEY
=
"vip_map"
override
fun
getName
():
String
{
return
"VIPStorage"
override
fun
getName
()
=
"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
{
prefs
.
edit
().
putString
(
number
,
name
).
apply
()
Log
.
d
(
"VIPStorage"
,
"Saved VIP $name -> $number"
)
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
)
}
}
// --- API: remove a VIP ---
@ReactMethod
fun
removeVIP
(
number
:
String
,
promise
:
Promise
)
{
try
{
val
key
=
normalize
(
number
)
val
map
=
readMap
()
map
.
remove
(
key
)
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