tru.ID SubscriberCheck offers both mobile phone number verification and SIM swap detection. SubscriberCheck achieves this by combining the workflows of PhoneCheck, which confirms the ownership of a mobile phone number by verifying the possession of an active SIM card with the same number, with SIMCheck, which provides information on when a SIM card associated with a mobile phone number was last changed. SubscriberCheck can be used when augmenting existing 2FA or anti-fraud workflows.
If you'd prefer to go directly to the completed code, it's in the sim-card-auth-ios Github repository.
Before you begin
For development you'll need:
- Xcode 12
- An Apple developer account via the Apple Developer Portal
- An iPhone or an iPad with a SIM card with an active data connection
- Node.js installed for the tru.ID CLI
With the above in place, let's dive straight into adding SubscriberCheck functionality to your iOS applications.
Set-up the tru.ID CLI and Run a Development Server
tru.ID provides a tru.ID CLI to quickly set up a development environment and provide a development server on your machine.
The development server acts as a proxy in between your mobile app and the tru.ID servers. It also opens up a local tunnel that makes the server publicly accessible over the Internet, so your mobile device can still connect to it when on cellular data. This architecture means you can focus on the mobile application development during this tutorial.
Your production architecture should mirror this client/server architecture (but may not be the same API), with your servers acting as a secure proxy to the tru.ID APIs. See the SubscriberCheck Workflow Integration guide for more details.
With the background information out of the way, sign up for a tru.ID account account. The account comes with some free credits, which you can use during the development and to test your app against the production environment when it is ready.
Next, install the tru.ID CLI:
npm install -g @tru_id/cli
Run the tru setup:credentials
command with the credentials you can copy from the tru.ID console:
Install the CLI development server plugin:
tru plugins:install @tru_id/cli-plugin-dev-server
Create a new tru.ID project:
tru projects:create iOSAuthDemoServer
This saves a tru.json
tru.ID project configuration to ./iosauthdemoserver/tru.json
.
Run the development server by pointing it to the newly created project directory and configuration.
tru server -t --project-dir ./iosauthdemoserver
Check that the URL that is shown in the terminal is accessible in your web browser. The URL is in the format https://{subdomain}.loca.lt
. This is the public accessible URL to your local development server.
Create a New iOS Project
With the tru.ID account created and the development server up and running, we can make a start with the application. You can skip this step if you already have an iOS project. Otherwise;
- Launch your Xcode
- File -> New -> Project
- In the "Choose a template for your new project" modal, select App and click Next
- Set "sim-card-auth-ios" as the Product Name, however, you can use whatever the name of your project is
- Select your Team, and make sure to assign an organization identifier using a reverse domain notation
- Keep it simple, and use a Storyboard, UIKit App Delegate and Swift as the development language
- Uncheck Use Code Data if it is checked, and click Next
- Select the folder you want to store your project in and click Next
As you see, it is a pretty simple project with a single ViewControlller. At this point, you do not need to worry about the AppDelegate
or SceneDelegate
. This is enough to demonstrate SubscriberCheck.
If you already have Xcode and have added your developer account (Xcode->Preferences->Accounts), Xcode takes care of generating necessary certificates and provisioning profiles in order to install the app on the device.
Build the User Interface
Navigate to the Main.storyboard
. You need to add a few UI components to receive input from the user, and provide feedback:
- Add a
UILabel
to the View Controller's view as a title with a text "Verification" - A
UIActivityIndicator
(Large) to show/hide progress when you perform a SubscriberCheck - A
UILabel
with a text "Phone number" to indicate what the next text field is for - A
UITextField
so that the user can enter their phone number - A
UIButton
to trigger the SubscriberCheck request - A
UImageView
to show whether SubscriberCheck is successful or not
All UI components are "Horizontally" aligned in the container using constraints. You should also need to define constraints to anchor the components as well.
Start with the top "Verification" UILabel
and specify the alignment between the Safe Area and the label. Do the same for all other components, define a constraint for the Top space, and where necessary, add additional constraints for width and height.
The view layout should look like this:
There are a few configuration options you should add for these UI components.
- Phone number
UITextField
: Select the text field, and on the Attributes Inspector, scroll toText Input Traits
and change theContent Type
toTelephone Number
. Also, change theKeyboard Type
toPhone Pad
. -
UIActivityIndicator
: Select the activity indicator, and on the Attributes Inspector checkHides When Stopped
-
UIImageView
: Select theUIImageView
, and on the Attributes Inspector, scroll to Drawing, and checkHidden
Now, you need to define Outlets in the ViewController so that you can control the UI state. Let's select ViewController
in Xcode, and then by using the ⌥
select Main.storyboard
file. Both ViewController.swift
and Main.storyboard
should be opened side by side.
Select the UIActivityIndicator
you inserted to the storyboard, and with ⌃
key pressed, drag a connection from the storyboard to the ViewController.swift
. Xcode indicates possible places in the Editor where you can create an Outlet.
When you are happy, release the keys and mouse/trackpad. You will be prompted to enter a name for the variable; type busyActivityIndicator
.
You need to connect the UITextField
, UIButton
and the UIImageView
as well. Let's perform the above steps for these as well respectively, and name them as follows:
- phoneNumberTextField
- nextButton
- checkResults
This allows you to retrieve the phone number entered by the user, and control the state to provide feedback. You now have one last task to do related to the storyboard.
Let's insert an action. When a user taps on the Next button, you want the ViewController
to know that the user wants to initiate the SubscriberCheck. So select the Next
button, and with your ⌃
key pressed drag a connection from the storyboard to the ViewController.swift
. Xcode indicates possible places where you can create an IBAction
. When you are happy, release the keys and mouse/trackpad. You will be prompted to enter a name for the method: type next
and Xcode will insert the method with a IBAction
annotation.
It is time to write some code to manage the UI state. The first method you are going to add is controls(enabled: Bool)
. This method helps us show or hide the checkResults
, and busyActivityIndicator
. You should also disable the phoneNumberTextField
when the SubscriberCheck flow is in progress.
// MARK: UI Controls Configure || Enable/Disable
private func controls(enabled: Bool) {
if enabled {
busyActivityIndicator.stopAnimating()
} else {
busyActivityIndicator.startAnimating()
}
phoneNumberTextField.isEnabled = enabled
nextButton.isEnabled = enabled
checkResults.isHidden = !enabled
}
private func configureCheckResults(match: Bool, noSimChange: Bool) {
if match {
let image = UIImage(systemName: "person.fill.checkmark")
self.checkResults.image = image?.withRenderingMode(.alwaysTemplate)
self.checkResults.tintColor = .green
} else {
let image = UIImage(systemName: "person.fill.xmark")
self.checkResults.image = image?.withRenderingMode(.alwaysTemplate)
self.checkResults.tintColor = .red
}
}
You will use these methods later in the next(_ sender: Any)
that is triggered by the user tapping the Next
button.
Models, Structs & Protocols
Now is the time to create a new group called service
in the Project Navigator. In here you'll implement Model layer classes, structs, protocols and enums. Note that none of the files in this group should be importing UIKit
.
Create a Swift file in the service
group called SessionEndpoint.swift
. In this file, we define a protocol called Endpoint
and NetworkError
of type Enum and a class which implements the protocol. Let's define the protocol Endpoint
and NetworkError
enum as in the following in this file.
import Foundation
protocol Endpoint {
var baseURL: String { get }
func makeRequest<U: Decodable>(urlRequest: URLRequest,
handler: @escaping (Result<U, NetworkError>) -> Void)
func createURLRequest(method: String,
url: URL,
payload:[String : String]?) -> URLRequest
}
enum NetworkError: Error {
case invalidURL
case connectionFailed(String)
case httpNotOK
case noData
}
The purpose of the Endpoint
protocol is to hide implementation details from the client of this protocol. It has two methods and a variable baseURL
. It represents one REST API endpoint. We'll come back to this in more detail later.
Next, define our model object which holds SubscriberCheck results.
Create a Swift file called SubscriberCheck.swift
in the service
, and implement a struct with the same name as below:
import Foundation
// Response model based on https://developer.tru.id/docs/reference/api#operation/create-subscriber-check
struct SubscriberCheck: Codable {
let check_id: String?
let check_url: String?
let status: SubscriberCheckStatus?
let match: Bool?
let no_sim_change: Bool?
}
enum SubscriberCheckStatus: String, Codable {
case ACCEPTED
case PENDING
case COMPLETED
case EXPIRED
case ERROR
}
tru-ID REST API documentation provides us the basis for this struct. In production, your architecture and servers may expose a different REST response model. Also important to note that SubscriberCheck
implements the Codable
protocol as this will help JSONSerialization.data(..)
to decode the json response to the SubscriberCheck
easily.
Next, define a protocol which will help the View layer to talk to the Model layer implementation.
The View layer of our application is concerned about what the user is going to request. At this layer, you shouldn't be concerned about "how" it is going to be done. Since it is all about SubscriberCheck, simple Subscriber
protocol which defines a function to receive a phone number and provides a closure for the SubscriberCheck results should be sufficient.
Create a Swift file in the service
group called SubscriberCheckService.swift
. In this file, define the Subscriber
protocol as the following:
protocol Subscriber {
func check(phoneNumber: String,
handler: @escaping (Result<SubscriberCheck, NetworkError>) -> Void)
}
Create a class called SubscriberCheckService
in the existing SubscriberCheckService.swift
file which implements the Subscriber
protocol.
final class SubscriberCheckService: Subscriber {
let path = "/subscriber-check"
init(){
}
public func check(phoneNumber: String, handler: @escaping (Result<SubscriberCheck, NetworkError>) -> Void) {
//Implement the workflow
}
}
A variable called path
is defined to hold the path for the development server endpoint to perform the necessary calls. The path is /subscriber-check
for the local development server. The check(phoneNumber: ...)
method will contain the logic for the workflow.
Implement the User Action
At this point, the UI is almost ready to execute the SubscriberCheck workflow. Let's first define a variable of Subscriber
type in our ViewController
and then implement the next(_ sender: Any)
IBAction we previously created.
Add the following code to the ViewController
.
var subscriberService: Subscriber!
override func viewDidLoad() {
super.viewDidLoad()
subscriberService = SubscriberCheckService()
}
This initialises subscriberService
with a concrete implementation SubscriberCheckService
which you defined in the previous section. SubscriberCheckService
knows how to execute the workflow and all ViewController
needs to do is to call check(phoneNumber: String, ..)
method and control the UI state. It is time to implement the next(_ sender: Any)
. It looks as follows:
@IBAction func next(_ sender: Any) {
guard let phoneNumber = phoneNumberTextField.text else {
return
}
if !phoneNumber.isEmpty {
// Ideally you should validated phone number against e164 spec
// Without leading + or 0's
// For example: {country_code}{number}, 447940448591
// Remove double 00's
var strippedPhoneNumber = phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
if let range = strippedPhoneNumber.range(of: "00") {
strippedPhoneNumber.replaceSubrange(range, with: "")
}
controls(enabled: false)
subscriberService.check(phoneNumber: strippedPhoneNumber) { [weak self] (checkResult) in
DispatchQueue.main.async {
switch checkResult {
case .success(let subscriberCheck):
self?.configureCheckResults(match: subscriberCheck.match ?? false, noSimChange: subscriberCheck.no_sim_change ?? false)
case .failure(let error):
print("\(error)")
}
self?.controls(enabled:true)
}
}
}
}
The implementation of the next
method first checks whether there is text in the phoneNumberTextField
and whether it is empty or not.
Note that in a production code, you should validate the phone number against the E.164 specification. We are keeping it simple for the purposes of this tutorial by only removing 00
from the beginning of the phone number (if it exists) and trimming.
The second step is to disable parts of the user interface, show the activity indicator and let it spin when the user taps the Next button. The third step is to call the check(phoneNumber:)
method of the subscriberService
. The handler will provide a checkResult
which is of type Result<SubscriberCheck,NetworkError>
. Note that this closure will not be called in the main queue, therefore you need to wrap any code which accesses UIKit in a DispatchQueue.main.async
.
If the workflow executes successfully then you can access the model details and reconfigure the UI. Note that the .success
case doesn't necessarily mean that validation is successful, it is simply an indication that workflow executed without encountering any network errors.
In order to understand if you validated the phone number you need to inspect the .success
payload which is of type SubscriberCheck
. The following line will ensure that validation results are reflected in the UI:
self?.configureCheckResults(match: subscriberCheck.match ?? false, noSimChange: subscriberCheck.no_sim_change ?? false)
In any case, you restore the UI controls back to their original state with the following code so that the user can re-execute the workflow, if needed:
self?.controls(enabled:true)
Setup the Network and Define Endpoints
Your app may have its own ways for defining and access external service URLs, and these endpoints may be stored in configuration files such as a plist, or in your Swift code. In this tutorial, you store the development and production base URLs in a plist called TruIdService-Info.plist
. These server endpoints proxy some of the requests through to the tru.ID API.
Create a group called util
in the Project Navigator. Next, create the Property List:
- File -> New -> File
- Select Property List in the dialog
- Click Next
- Select where you want to store the file (default selected folder should be fine)
- Type
TruIdService-Info
as the file name - Click Create
You should see this file created in the util
group. Now, let's add two keys to this plist
file; one for the development endpoints and one for the production endpoints. The values should be String type.
development_server_base_url
production_server_base_url
You must ensure to assign the correct value to the development_server_base_url
in order to complete this tutorial and successfully run the app on your device. This value is the one you are provided from the Terminal when you set up and ran your development server at the beginning of this tutorial (e.g. https://{subdomain}.loca.lt
). For production setup, you should implement your own backend and add this URL here in the production_server_base_url
.
In order to read the plist, create a struct
called AppConfiguration
, which deals with loading the correct endpoint so you do not have to worry when you are implementing the use cases.
import Foundation
struct AppConfiguration {
let defaultConfigurationName = "TruIdService-Info"
var configuration: [String : Any]?
mutating func loadServerConfiguration() {
if let path = Bundle.main.path(forResource: defaultConfigurationName, ofType: "plist"),
let xml = FileManager.default.contents(atPath: path)
{
self.configuration = (try? PropertyListSerialization.propertyList(from: xml,
options: .mutableContainersAndLeaves,
format: nil)) as? [String : Any]
}
}
func baseURL() -> String? {
var key = "production_server_base_url"
#if DEBUG
key = "development_server_base_url"
#endif
return configuration?[key] as? String
}
}
The AppConfiguration
struct simply reaches into the main bundle and searches for a plist
called TruIdService-Info
. If found, it reads the plist as a dictionary and binds that to the configuration
variable. This URL is provided to the clients of the struct via the baseURL() -> String?
method.
Now, implement a class called SessionEndpoint
within the existing service/SessionEndpoint.swift
file. This is our implementation of simple network requests using URLSession
. You can implement this protocol using URLSession, or with Alamofire. For the purposes of this tutorial, let's keep it simple and implement the protocol using URLSession.
final class SessionEndpoint: Endpoint {
let baseURL: String
private let session: URLSession
init() {
var configuration = AppConfiguration()
configuration.loadServerConfiguration()
baseURL = configuration.baseURL()!//Fail early so that you know there is something wrong
session = SessionEndpoint.createSession()
}
private static func createSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral //you do not want OS to cache or persist
configuration.allowsCellularAccess = true
configuration.waitsForConnectivity = true
configuration.networkServiceType = .responsiveData
return URLSession(configuration: configuration)
}
// MARK: Protocol Implementation
func makeRequest<U: Decodable>(urlRequest: URLRequest,
handler: @escaping (Result<U, NetworkError>) -> Void) {
let task = session.dataTask(with: urlRequest) { (data, response, error) in
if let error = error {
handler(.failure(.connectionFailed(error.localizedDescription)))
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
handler(.failure(.connectionFailed("HTTP not OK")))
return
}
guard let data = data else {
handler(.failure(.noData))
return
}
print("Response: \(String(describing: String(data: data, encoding: .utf8)))")
if let dataModel = try? JSONDecoder().decode(U.self, from: data) {
handler(.success(dataModel))
return
}
}
task.resume()
}
func createURLRequest(method: String, url: URL, payload:[String : String]?) -> URLRequest {
var urlRequest = URLRequest(url: url)
urlRequest.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
urlRequest.httpMethod = method
if let payload = payload {
let jsonData = try! JSONSerialization.data(withJSONObject: payload, options: .prettyPrinted)
urlRequest.httpBody = jsonData
}
return urlRequest
}
}
The init()
method of the class loads a base URL from the AppConfiguration
which you defined earlier. It returns a URL for either a development server or a production server, depending on the build scheme. The final line of the init()
creates a URLSession using a private static method. Note that the createSession()
method creates a session configuration which doesn't cache or persist network related information; it is ephemeral
for additional security.
The rest of the file contains the Endpoint
protocol implementation. The first method, makeRequest<>..
, creates a data task using the URLRequest
provided and initiates the call. When the response is received, the method calls the handler
closure for success or failure cases. If data exists and there are no error scenarios, it attempts to decode the data to the model type provided.
The Result<>
generic type refers to a model object and an Enum
which provides error cases.
The createURLRequest(..)
method receives three parameters; HTTP method name, the URL and an optional payload if the request is a POST request, for instance. The method returns a URLRequest
object, which is then used by the makeRequest<>..
method during the execution of the workflow.
Implement the Workflow
Now that you have defined the user interface and network request/response mechanics, let's bridge the two and implement the business logic. It is time to talk about the SubscriberCheck workflow before you dive more into the coding.
The SubscriberCheck workflow has 3 steps:
- Create a SubscriberCheck via the server. This will return a
check_id
andcheck_url
. - Then request the
check_url
retrieved in step 1 using the tru.ID iOS SDK - As soon as
check_url
request returns, retrieve the SubscriberCheck results using thecheck_id
retrieved in step 1
The following sequence diagram shows each step, including the server to tru.ID API interactions:
Let's implement the workflow in SubscriberCheckService
using some helper methods and the classes you created in the previous sections.
First, let's add a variable called endpoint
to the SubscriberCheckService
and initiate it in the init()
method. The SubscriberCheckService
class uses a concrete implementation of Endpoint
protocol called SessionEndpoint
which you defined in the previous sections. This class makes it easier to execute network calls.
final class SubscriberCheckService: Subscriber {
let path = "/subscriber-check"
let endpoint: Endpoint
init(){
self.endpoint = SessionEndpoint()
}
...
}
Let's also define three methods, each corresponding to the steps mentioned above. Later, we will stitch the steps of the workflow in check(phoneNumber: ...)
method.
The first method is createSubscriberCheck(phoneNumber:...)
. It makes a POST
request to the server to create a SubscriberCheck. This call can be made over any type of network (cellular/wifi). The server should return a SubscriberCheck check_url
.
Add the following method to the SubscriberCheckService
class:
private func createSubscriberCheck(phoneNumber: String,
handler: @escaping (Result<SubscriberCheck, NetworkError>) -> Void) {
let urlString = endpoint.baseURL + path
guard let url = URL(string: urlString) else {
handler(.failure(.invalidURL))
return
}
let phoneNumberDict = ["phone_number" : phoneNumber]
let urlRequest = endpoint.createURLRequest(method: "POST", url: url, payload: phoneNumberDict)
endpoint.makeRequest(urlRequest: urlRequest, handler: handler)
}
This method receives a phone number, constructs the full URL using the endpoint.baseURL
and the SubscriberCheck path
which is defined by the server. It creates a payload (just the phone number), and a URLRequest
using the endpoint.createURLRequest(..)
method. Then the method uses the makeRequest(..)
method of the endpoint and passes the urlRequest
and the handler.
Our next method is called retrieveSubscriberCheck(..)
. Add it to the SubscriberCheckService
class:
private func retrieveSubscriberCheck(checkId: String,
handler: @escaping (Result<SubscriberCheck, NetworkError>) -> Void) {
let urlString = endpoint.baseURL + path + "/" + checkId
guard let url = URL(string: urlString) else {
handler(.failure(.invalidURL))
return
}
let urlRequest = endpoint.createURLRequest(method: "GET", url: url, payload: nil)
endpoint.makeRequest(urlRequest: urlRequest, handler: handler)
}
This is very similar to the first method you defined, with a few differences. You are now calling the endpoint with an extra checkId
parameter, and this time it is a GET
call. This method will get the results of the check performed.
The third method we need to create is doing the heavy lifting using the tru.ID SDK. Let's create a method called requestSubscriberCheckURL(..)
:
private func requestSubscriberCheckURL(subscriberCheckURL: String,
handler: @escaping () -> Void) {
}
This method requests the check_url
which will be returned by the createSubscriberCheck(..)
. In order to do that you need the tru.Id iOS SDK. Let's add it to our project.
The tru.ID iOS SDK ensures that certain network calls are done on a Cellular network type, which is needed to run SubscriberCheck workflow. You have two options to add the SDK to your project.
Using Swift Package Manager
Xcode integrates well with Github, and you can add Swift Packages very easily. In your Xcode, go to File -> Swift Packages -> Add Package Dependency...
Type https://github.com/tru-ID/tru-sdk-ios.git
, and tap Next.
Xcode will find the package, select the latest version and then tap Finish to add it. Now that the SDK is added, you can import it when you are implementing the workflow.
Using CocoaPods
While we recommend using Swift Package Manager, tru.ID iOS SDK also supports adding your dependencies via CocoaPods. If you are familiar with CocoaPods and prefer using it, all you need to do is to create a Podfile and add the tru.ID pod the following way:
target 'MyApp' do
pod 'tru-sdk-ios'
end
Make sure to run pod install
in your project directory. After the CocoaPods install all necessary pods, and configure your project, don't forget to open the workspace rather than the project file.
Request the check_url
Let's go back to the SubscriberCheckService
and import the TruSDK
:
import TruSDK
Then use the SDK within requestSubscriberCheckURL(..)
to request the check_url
:
private func requestSubscriberCheckURL(subscriberCheckURL: String,
handler: @escaping () -> Void) {
let tru = TruSDK()
tru.openCheckUrl(url: subscriberCheckURL) { (something) in
handler()
}
}
It's that simple.
As discussed, the SDK will ensure that this call will be made over the cellular network. When the openCheckUrl(..)
calls the closure, you call the handler
as well.
You are yet to implement the SubscriberCheckService
check(phoneNumber:...)
method, and chain the methods in our check(phoneNumber: String..
method. So, let's do that:
public func check(phoneNumber: String, handler: @escaping (Result<SubscriberCheck, NetworkError>) -> Void) {
createSubscriberCheck(phoneNumber: phoneNumber) { (createResult) in
var checkURL = ""
var checkID = ""
switch createResult {
case .success(let subscriberCheck):
// The server returns the SubscriberCheck results to the device.
checkURL = subscriberCheck.check_url!
checkID = subscriberCheck.check_id!
print("Got the subscriber check URL: \(String(describing: subscriberCheck.check_url)) ")
case .failure(let error):
handler(.failure(error))
return
}
print("Using the SDK to request check URL over mobile network")
self.requestSubscriberCheckURL(subscriberCheckURL: checkURL) { [weak self] in
guard let self = self else {
return
}
print("SDK successfully returned, let's call our server to retrieve check results.")
self.retrieveSubscriberCheck(checkId: checkID) { (checkResult) in
switch checkResult {
case .success(let checkResultModel):
handler(.success(checkResultModel))
case .failure(let error):
handler(.failure(error))
}
}
}
}
}
First, you make a call using the createSubscriberCheck(phoneNumber: phoneNumber) ...
method. The callback to this method inspects the Result<>
. If it is a success, it fetches the checkURL
and checkID
and stores them in local variables which will be used later.
Within the callback you execute the second step using requestSubscriberCheckURL(...)
. This method uses the tru.ID iOS SDK and makes a call to check_url
. The SDK makes this call over the cellular network, so the user must have a data plan. Behind the scenes, this call will redirect and eventually return OK. All this will be handled by the SDK.
The third step is to make a final request to the server using the checkID
that you've got as a result of making the first call. This call will return the SubscriberCheck result; whether the check is successful or not.
Perform your first SubscriberCheck
Now that our code is complete, you can run the application on a real device. Bear in mind that SIM card based authentication is not possible on a Simulator, as you require a SIM Card with an active data connection.
You've now integrated SubscriberCheck into an iOS application, enabling you to both verify a phone number and check if the SIM card associated with the phone number has changed recently. All powered by SIM card based mobile authentication.
Where next?
- The completed sample app can be found in the tru.ID sim-card-auth-ios Github repository.
- Take a look at the tru.ID iOS SDK on GitHub
Troubleshooting
If you have any questions please raise an issue on the GitHub repo.
Mobile Data is Required
Don't forget that the SubscriberCheck validation requires your mobile device to have a data plan from your network operator, and that you should enable mobile data.
Get in touch
If you have any questions, get in touch via help@tru.id. Or throw me a message on Twitter
Top comments (0)