Browse Source

Merge pull request #8 from vernu/feature/receive-sms

Receive Messages Feature
pull/19/head v2.3.0
Israel Abebe 2 years ago
committed by GitHub
parent
commit
28d40e05b4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 14
      README.md
  2. 15
      android/app/build.gradle
  3. 34
      android/app/src/main/AndroidManifest.xml
  4. 33
      android/app/src/main/java/com/vernu/sms/ApiManager.java
  5. 19
      android/app/src/main/java/com/vernu/sms/AppConstants.java
  6. 53
      android/app/src/main/java/com/vernu/sms/TextBeeUtils.java
  7. 242
      android/app/src/main/java/com/vernu/sms/activities/MainActivity.java
  8. 25
      android/app/src/main/java/com/vernu/sms/database/local/AppDatabase.java
  9. 17
      android/app/src/main/java/com/vernu/sms/database/local/DateConverter.java
  10. 193
      android/app/src/main/java/com/vernu/sms/database/local/SMS.java
  11. 27
      android/app/src/main/java/com/vernu/sms/database/local/SMSDao.java
  12. 6
      android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java
  13. 42
      android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java
  14. 9
      android/app/src/main/java/com/vernu/sms/dtos/SMSForwardResponseDTO.java
  15. 7
      android/app/src/main/java/com/vernu/sms/helpers/SharedPreferenceHelper.java
  16. 25
      android/app/src/main/java/com/vernu/sms/models/SMSPayload.java
  17. 21
      android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java
  18. 101
      android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java
  19. 16
      android/app/src/main/java/com/vernu/sms/services/FCMService.java
  20. 11
      android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java
  21. 82
      android/app/src/main/java/com/vernu/sms/services/StickyNotificationService.java
  22. 184
      android/app/src/main/res/layout/activity_main.xml
  23. 1
      api/.env.example
  24. 17
      api/src/auth/auth.controller.ts
  25. 7
      api/src/auth/auth.module.ts
  26. 37
      api/src/auth/auth.service.ts
  27. 21
      api/src/auth/guards/auth.guard.ts
  28. 31
      api/src/auth/schemas/access-log.schema.ts
  29. 6
      api/src/auth/schemas/api-key.schema.ts
  30. 62
      api/src/gateway/gateway.controller.ts
  31. 134
      api/src/gateway/gateway.dto.ts
  32. 5
      api/src/gateway/gateway.module.ts
  33. 112
      api/src/gateway/gateway.service.ts
  34. 3
      api/src/gateway/schemas/device.schema.ts
  35. 45
      api/src/gateway/schemas/sms.schema.ts
  36. 4
      api/src/gateway/sms-type.enum.ts
  37. 5
      api/src/main.ts
  38. 2
      web/components/Footer.tsx
  39. 18
      web/components/Navbar.tsx
  40. 29
      web/components/dashboard/APIKeyAndDevices.tsx
  41. 2
      web/components/dashboard/ApiKeyList.tsx
  42. 2
      web/components/dashboard/DeviceList.tsx
  43. 170
      web/components/dashboard/ReceiveSMS.tsx
  44. 77
      web/components/dashboard/SendSMS.tsx
  45. 38
      web/components/dashboard/UserStats.tsx
  46. 9
      web/components/dashboard/UserStatsCard.tsx
  47. 10
      web/components/landing/CodeSnippetSection.tsx
  48. 2
      web/components/landing/DownloadAppSection.tsx
  49. 2
      web/components/landing/howItWorksContent.ts
  50. 16
      web/components/meta/Meta.tsx
  51. 2
      web/next.config.js
  52. 60
      web/pages/dashboard.tsx
  53. 5
      web/services/authService.ts
  54. 5
      web/services/gatewayService.ts
  55. 4
      web/services/types.ts
  56. 35
      web/store/deviceSlice.ts
  57. 3
      web/store/statsSlice.ts

14
README.md

@ -11,7 +11,7 @@ from their application via a REST API. It utilizes android phones as SMS gateway
## Usage
1. Go to [textbee.dev](https://textbee.dev) and register or login with your account
2. Install the app on your android phone from [textbee.dev/android](https://textbee.dev/android)
2. Install the app on your android phone from [dl.textbee.dev](https://dl.textbee.dev)
3. Open the app and grant the permissions for SMS
4. Go to [textbee.dev/dashboard](https://textbee.dev/dashboard) and click register device/ generate API Key
5. Scan the QR code with the app or enter the API key manually
@ -23,10 +23,14 @@ from their application via a REST API. It utilizes android phones as SMS gateway
const API_KEY = 'YOUR_API_KEY';
const DEVICE_ID = 'YOUR_DEVICE_ID';
await axios.post(`https://api.textbee.dev/api/v1/gateway/devices/${DEVICE_ID}/sendSMS?apiKey=${API_KEY}`, {
receivers: [ '+251912345678' ],
smsBody: 'Hello World!',
})
await axios.post(`https://api.textbee.dev/api/v1/gateway/devices/${DEVICE_ID}/sendSMS`, {
recipients: [ '+251912345678' ],
message: 'Hello World!',
}, {
headers: {
'x-api-key': API_KEY,
},
});
```

15
android/app/build.gradle

@ -10,10 +10,16 @@ android {
applicationId "com.vernu.sms"
minSdk 24
targetSdk 32
versionCode 9
versionName "2.2.0"
versionCode 10
versionName "2.3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// javaCompileOptions {
// annotationProcessorOptions {
// arguments["room.schemaLocation"] = "$projectDir/schemas"
// }
// }
}
buildTypes {
@ -46,4 +52,9 @@ dependencies {
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.journeyapps:zxing-android-embedded:4.1.0'
// def room_version = "2.4.2"
// implementation "androidx.room:room-runtime:$room_version"
// annotationProcessor "androidx.room:room-compiler:$room_version"
}

34
android/app/src/main/AndroidManifest.xml

@ -3,8 +3,17 @@
xmlns:tools="http://schemas.android.com/tools"
package="com.vernu.sms">
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.provider.Telephony.SMS_RECEIVED" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@ -21,8 +30,31 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name=".services.StickyNotificationService"
android:enabled="true"
android:exported="false">
</service>
<receiver
android:name=".receivers.SMSBroadcastReceiver"
android:exported="true">
<intent-filter
android:priority="2147483647">
<action android:name="android.provider.Telephony.SMS_RECEIVED"/>
</intent-filter>
</receiver>
<receiver android:enabled="true"
android:name=".receivers.BootCompletedReceiver"
android:exported="true"
android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<activity
android:name="com.vernu.sms.activities.MainActivity"
android:name=".activities.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

33
android/app/src/main/java/com/vernu/sms/ApiManager.java

@ -0,0 +1,33 @@
package com.vernu.sms;
import com.vernu.sms.services.GatewayApiService;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
public class ApiManager {
private static GatewayApiService apiService;
public static GatewayApiService getApiService() {
if (apiService == null) {
apiService = createApiService();
}
return apiService;
}
private static GatewayApiService createApiService() {
// OkHttpClient.Builder httpClient = new OkHttpClient.Builder();
// HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor();
// loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
// httpClient.addInterceptor(loggingInterceptor);
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(AppConstants.API_BASE_URL)
// .client(httpClient.build())
.addConverterFactory(GsonConverterFactory.create())
.build();
apiService = retrofit.create(GatewayApiService.class);
return retrofit.create(GatewayApiService.class);
}
}

19
android/app/src/main/java/com/vernu/sms/AppConstants.java

@ -0,0 +1,19 @@
package com.vernu.sms;
import android.Manifest;
public class AppConstants {
public static final String API_BASE_URL = "https://api.textbee.dev/api/v1/";
public static final String[] requiredPermissions = new String[]{
Manifest.permission.SEND_SMS,
Manifest.permission.READ_SMS,
Manifest.permission.RECEIVE_SMS,
Manifest.permission.READ_PHONE_STATE
};
public static final String SHARED_PREFS_DEVICE_ID_KEY = "DEVICE_ID";
public static final String SHARED_PREFS_API_KEY_KEY = "API_KEY";
public static final String SHARED_PREFS_GATEWAY_ENABLED_KEY = "GATEWAY_ENABLED";
public static final String SHARED_PREFS_PREFERRED_SIM_KEY = "PREFERRED_SIM";
public static final String SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY = "RECEIVE_SMS_ENABLED";
public static final String SHARED_PREFS_TRACK_SENT_SMS_STATUS_KEY = "TRACK_SENT_SMS_STATUS";
}

53
android/app/src/main/java/com/vernu/sms/TextBeeUtils.java

@ -0,0 +1,53 @@
package com.vernu.sms;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.telephony.SubscriptionInfo;
import android.telephony.SubscriptionManager;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.vernu.sms.services.StickyNotificationService;
import java.util.ArrayList;
import java.util.List;
public class TextBeeUtils {
public static boolean isPermissionGranted(Context context, String permission) {
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED;
}
public static List<SubscriptionInfo> getAvailableSimSlots(Context context) {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
return new ArrayList<>();
}
SubscriptionManager subscriptionManager = SubscriptionManager.from(context);
return subscriptionManager.getActiveSubscriptionInfoList();
}
public static void startStickyNotificationService(Context context) {
if(!isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)){
return;
}
Intent notificationIntent = new Intent(context, StickyNotificationService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(notificationIntent);
} else {
context.startService(notificationIntent);
}
}
public static void stopStickyNotificationService(Context context) {
Intent notificationIntent = new Intent(context, StickyNotificationService.class);
context.stopService(notificationIntent);
}
}

242
android/app/src/main/java/com/vernu/sms/activities/MainActivity.java

@ -3,18 +3,12 @@ package com.vernu.sms.activities;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import android.Manifest;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.telephony.SubscriptionInfo;
import android.telephony.SubscriptionManager;
import android.util.Log;
import android.view.View;
import android.widget.Button;
@ -25,95 +19,57 @@ import android.widget.RadioGroup;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import com.google.android.material.snackbar.Snackbar;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.zxing.integration.android.IntentIntegrator;
import com.google.zxing.integration.android.IntentResult;
import com.vernu.sms.services.GatewayApiService;
import com.vernu.sms.ApiManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.BuildConfig;
import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.R;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import java.util.ArrayList;
import java.util.List;
import java.util.Arrays;
import java.util.Objects;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
public class MainActivity extends AppCompatActivity {
private Context mContext;
private Retrofit retrofit;
private GatewayApiService gatewayApiService;
private Switch gatewaySwitch;
private Switch gatewaySwitch, receiveSMSSwitch;
private EditText apiKeyEditText, fcmTokenEditText;
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn;
private ImageButton copyDeviceIdImgBtn;
private TextView deviceBrandAndModelTxt, deviceIdTxt;
private RadioGroup defaultSimSlotRadioGroup;
private static final int SEND_SMS_PERMISSION_REQUEST_CODE = 0;
private static final int SCAN_QR_REQUEST_CODE = 49374;
private static final String API_BASE_URL = "https://api.textbee.dev/api/v1/";
private static final int PERMISSION_REQUEST_CODE = 0;
private String deviceId = null;
private static final String TAG = "MainActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mContext = getApplicationContext();
retrofit = new Retrofit.Builder()
.baseUrl(API_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build();
gatewayApiService = retrofit.create(GatewayApiService.class);
deviceId = SharedPreferenceHelper.getSharedPreferenceString(mContext, "DEVICE_ID", "");
deviceId = SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
setContentView(R.layout.activity_main);
gatewaySwitch = findViewById(R.id.gatewaySwitch);
receiveSMSSwitch = findViewById(R.id.receiveSMSSwitch);
apiKeyEditText = findViewById(R.id.apiKeyEditText);
fcmTokenEditText = findViewById(R.id.fcmTokenEditText);
registerDeviceBtn = findViewById(R.id.registerDeviceBtn);
grantSMSPermissionBtn = findViewById(R.id.grantSMSPermissionBtn);
scanQRBtn = findViewById(R.id.scanQRButton);
deviceBrandAndModelTxt = findViewById(R.id.deviceBrandAndModelTxt);
deviceIdTxt = findViewById(R.id.deviceIdTxt);
copyDeviceIdImgBtn = findViewById(R.id.copyDeviceIdImgBtn);
defaultSimSlotRadioGroup = findViewById(R.id.defaultSimSlotRadioGroup);
try {
getAvailableSimSlots().forEach(subscriptionInfo -> {
RadioButton radioButton = new RadioButton(mContext);
radioButton.setText(subscriptionInfo.getDisplayName().toString());
radioButton.setId(subscriptionInfo.getSubscriptionId());
radioButton.setOnClickListener(view -> {
SharedPreferenceHelper.setSharedPreferenceInt(mContext, "PREFERED_SIM", subscriptionInfo.getSubscriptionId());
});
radioButton.setChecked(subscriptionInfo.getSubscriptionId() == SharedPreferenceHelper.getSharedPreferenceInt(mContext, "PREFERED_SIM", 0));
defaultSimSlotRadioGroup.addView(radioButton);
});
} catch (Exception e) {
Snackbar.make(defaultSimSlotRadioGroup.getRootView(), "Error: " + e.getMessage(), Snackbar.LENGTH_LONG).show();
Log.e("SIM_SLOT_ERROR", e.getMessage());
}
deviceIdTxt.setText(deviceId);
deviceBrandAndModelTxt.setText(Build.BRAND + " " + Build.MODEL);
@ -123,14 +79,19 @@ public class MainActivity extends AppCompatActivity {
registerDeviceBtn.setText("Update");
}
if (isSMSPermissionGranted(mContext) && isReadPhoneStatePermissionGranted(mContext)) {
String[] missingPermissions = Arrays.stream(AppConstants.requiredPermissions).filter(permission -> !TextBeeUtils.isPermissionGranted(mContext, permission)).toArray(String[]::new);
if (missingPermissions.length == 0) {
grantSMSPermissionBtn.setEnabled(false);
grantSMSPermissionBtn.setText("SMS Permission Granted");
grantSMSPermissionBtn.setText("Permission Granted");
renderAvailableSimOptions();
} else {
Snackbar.make(grantSMSPermissionBtn, "Please Grant Required Permissions to continue: " + Arrays.toString(missingPermissions), Snackbar.LENGTH_SHORT).show();
grantSMSPermissionBtn.setEnabled(true);
grantSMSPermissionBtn.setOnClickListener(view -> handleSMSRequestPermission(view));
grantSMSPermissionBtn.setOnClickListener(this::handleRequestPermissions);
}
// TextBeeUtils.startStickyNotificationService(mContext);
copyDeviceIdImgBtn.setOnClickListener(view -> {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText("Device ID", deviceId);
@ -138,72 +99,122 @@ public class MainActivity extends AppCompatActivity {
Snackbar.make(view, "Copied", Snackbar.LENGTH_LONG).show();
});
apiKeyEditText.setText(SharedPreferenceHelper.getSharedPreferenceString(mContext, "API_KEY", ""));
gatewaySwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, "GATEWAY_ENABLED", false));
apiKeyEditText.setText(SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, ""));
gatewaySwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false));
gatewaySwitch.setOnCheckedChangeListener((compoundButton, isCheked) -> {
View view = compoundButton.getRootView();
compoundButton.setEnabled(false);
String key = apiKeyEditText.getText().toString();
RegisterDeviceInputDTO registerDeviceInput = new RegisterDeviceInputDTO();
registerDeviceInput.setEnabled(isCheked);
registerDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
registerDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
Call<RegisterDeviceResponseDTO> apiCall = gatewayApiService.updateDevice(deviceId, key, registerDeviceInput);
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceId, key, registerDeviceInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
if (response.isSuccessful()) {
Snackbar.make(view, "Gateway " + (isCheked ? "enabled" : "disabled"), Snackbar.LENGTH_LONG).show();
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, "GATEWAY_ENABLED", isCheked);
compoundButton.setChecked(Boolean.TRUE.equals(response.body().data.get("enabled")));
} else {
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show();
compoundButton.setEnabled(true);
return;
}
Snackbar.make(view, "Gateway " + (isCheked ? "enabled" : "disabled"), Snackbar.LENGTH_LONG).show();
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, isCheked);
boolean enabled = Boolean.TRUE.equals(Objects.requireNonNull(response.body()).data.get("enabled"));
compoundButton.setChecked(enabled);
// if (enabled) {
// TextBeeUtils.startStickyNotificationService(mContext);
// } else {
// TextBeeUtils.stopStickyNotificationService(mContext);
// }
compoundButton.setEnabled(true);
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Snackbar.make(view, "An error occured :(", Snackbar.LENGTH_LONG).show();
compoundButton.setEnabled(true);
}
});
});
receiveSMSSwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false));
receiveSMSSwitch.setOnCheckedChangeListener((compoundButton, isCheked) -> {
View view = compoundButton.getRootView();
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, isCheked);
compoundButton.setChecked(isCheked);
Snackbar.make(view, "Receive SMS " + (isCheked ? "enabled" : "disabled"), Snackbar.LENGTH_LONG).show();
});
// TODO: check gateway status/api key/device validity and update UI accordingly
registerDeviceBtn.setOnClickListener(view -> handleRegisterDevice());
scanQRBtn.setOnClickListener(view -> {
IntentIntegrator intentIntegrator = new IntentIntegrator(MainActivity.this);
intentIntegrator.setPrompt("Go to textbee.dev/dashboard and click Register Device to generate QR Code");
intentIntegrator.setRequestCode(SCAN_QR_REQUEST_CODE);
intentIntegrator.initiateScan();
});
}
private void renderAvailableSimOptions() {
try {
defaultSimSlotRadioGroup.removeAllViews();
RadioButton defaultSimSlotRadioBtn = new RadioButton(mContext);
defaultSimSlotRadioBtn.setText("Device Default");
defaultSimSlotRadioBtn.setId((int)123456);
defaultSimSlotRadioGroup.addView(defaultSimSlotRadioBtn);
TextBeeUtils.getAvailableSimSlots(mContext).forEach(subscriptionInfo -> {
String simInfo = "SIM " + (subscriptionInfo.getSimSlotIndex() + 1) + " (" + subscriptionInfo.getDisplayName() + ")";
RadioButton radioButton = new RadioButton(mContext);
radioButton.setText(simInfo);
radioButton.setId(subscriptionInfo.getSubscriptionId());
defaultSimSlotRadioGroup.addView(radioButton);
});
int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
if (preferredSim == -1) {
defaultSimSlotRadioGroup.check(defaultSimSlotRadioBtn.getId());
} else {
defaultSimSlotRadioGroup.check(preferredSim);
}
defaultSimSlotRadioGroup.setOnCheckedChangeListener((radioGroup, i) -> {
RadioButton radioButton = findViewById(i);
if (radioButton == null) {
return;
}
radioButton.setChecked(true);
if("Device Default".equals(radioButton.getText().toString())) {
SharedPreferenceHelper.clearSharedPreference(mContext, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY);
} else {
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, radioButton.getId());
}
});
} catch (Exception e) {
Snackbar.make(defaultSimSlotRadioGroup.getRootView(), "Error: " + e.getMessage(), Snackbar.LENGTH_LONG).show();
Log.e(TAG, "SIM_SLOT_ERROR "+ e.getMessage());
}
}
@Override
public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
switch (requestCode) {
case SEND_SMS_PERMISSION_REQUEST_CODE: {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Toast.makeText(mContext, "Yay! Permission Granted.", Toast.LENGTH_LONG).show();
} else {
Toast.makeText(mContext, "Permission Denied :(", Toast.LENGTH_LONG).show();
if (requestCode != PERMISSION_REQUEST_CODE) {
return;
}
boolean allPermissionsGranted = Arrays.stream(permissions).allMatch(permission -> TextBeeUtils.isPermissionGranted(mContext, permission));
if (allPermissionsGranted) {
Snackbar.make(findViewById(R.id.grantSMSPermissionBtn), "All Permissions Granted", Snackbar.LENGTH_SHORT).show();
grantSMSPermissionBtn.setEnabled(false);
grantSMSPermissionBtn.setText("Permission Granted");
renderAvailableSimOptions();
} else {
Snackbar.make(findViewById(R.id.grantSMSPermissionBtn), "Please Grant Required Permissions to continue", Snackbar.LENGTH_SHORT).show();
}
}
}
private void handleRegisterDevice() {
String newKey = apiKeyEditText.getText().toString();
@ -230,86 +241,63 @@ public class MainActivity extends AppCompatActivity {
registerDeviceInput.setModel(Build.MODEL);
registerDeviceInput.setBuildId(Build.ID);
registerDeviceInput.setOs(Build.VERSION.BASE_OS);
registerDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
registerDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
Call<RegisterDeviceResponseDTO> apiCall = gatewayApiService.registerDevice(newKey, registerDeviceInput);
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().registerDevice(newKey, registerDeviceInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
if (response.isSuccessful()) {
SharedPreferenceHelper.setSharedPreferenceString(mContext, "API_KEY", newKey);
Log.e("API_RESP", response.toString());
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show();
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
return;
}
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey);
Snackbar.make(view, "Device Registration Successful :)", Snackbar.LENGTH_LONG).show();
deviceId = response.body().data.get("_id").toString();
deviceIdTxt.setText(deviceId);
SharedPreferenceHelper.setSharedPreferenceString(mContext, "DEVICE_ID", deviceId);
} else {
Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show();
}
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Snackbar.make(view, "An error occured :(", Snackbar.LENGTH_LONG).show();
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
});
});
}
private void handleSMSRequestPermission(View view) {
if (isSMSPermissionGranted(mContext) && isReadPhoneStatePermissionGranted(mContext)) {
private void handleRequestPermissions(View view) {
boolean allPermissionsGranted = Arrays.stream(AppConstants.requiredPermissions).allMatch(permission -> TextBeeUtils.isPermissionGranted(mContext, permission));
if (allPermissionsGranted) {
Snackbar.make(view, "Already got permissions", Snackbar.LENGTH_SHORT).show();
} else {
Snackbar.make(view, "Grant SMS Permissions to continue", Snackbar.LENGTH_SHORT).show();
ActivityCompat.requestPermissions(MainActivity.this,
new String[]{Manifest.permission.SEND_SMS, Manifest.permission.READ_PHONE_STATE
}, SEND_SMS_PERMISSION_REQUEST_CODE);
return;
}
String[] permissionsToRequest = Arrays.stream(AppConstants.requiredPermissions).filter(permission -> !TextBeeUtils.isPermissionGranted(mContext, permission)).toArray(String[]::new);
Snackbar.make(view, "Please Grant Required Permissions to continue", Snackbar.LENGTH_SHORT).show();
ActivityCompat.requestPermissions(this, permissionsToRequest, PERMISSION_REQUEST_CODE);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == SCAN_QR_REQUEST_CODE) {
IntentResult intentResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);
if (intentResult != null) {
if (intentResult.getContents() == null) {
if (intentResult == null || intentResult.getContents() == null) {
Toast.makeText(getBaseContext(), "Canceled", Toast.LENGTH_SHORT).show();
} else {
return;
}
String scannedQR = intentResult.getContents();
apiKeyEditText.setText(scannedQR);
handleRegisterDevice();
}
}
}
}
private boolean isSMSPermissionGranted(Context context) {
return ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED;
}
private boolean isReadPhoneStatePermissionGranted(Context context) {
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED;
}
private List<SubscriptionInfo> getAvailableSimSlots() {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
return new ArrayList<>();
}
SubscriptionManager subscriptionManager = SubscriptionManager.from(mContext);
return subscriptionManager.getActiveSubscriptionInfoList();
}
}

25
android/app/src/main/java/com/vernu/sms/database/local/AppDatabase.java

@ -0,0 +1,25 @@
//package com.vernu.sms.database.local;
//
//import android.content.Context;
//import androidx.room.Database;
//import androidx.room.Room;
//import androidx.room.RoomDatabase;
//
//@Database(entities = {SMS.class}, version = 2)
//public abstract class AppDatabase extends RoomDatabase {
// private static volatile AppDatabase INSTANCE;
//
// public static AppDatabase getInstance(Context context) {
// if (INSTANCE == null) {
// synchronized (AppDatabase.class) {
// if (INSTANCE == null) {
// INSTANCE = Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, "db1")
// .build();
// }
// }
// }
// return INSTANCE;
// }
//
// public abstract SMSDao localReceivedSMSDao();
//}

17
android/app/src/main/java/com/vernu/sms/database/local/DateConverter.java

@ -0,0 +1,17 @@
//package com.vernu.sms.database.local;
//
//import androidx.room.TypeConverter;
//
//import java.util.Date;
//
//public class DateConverter {
// @TypeConverter
// public static Date toDate(Long dateLong) {
// return dateLong == null ? null : new Date(dateLong);
// }
//
// @TypeConverter
// public static Long fromDate(Date date) {
// return date == null ? null : date.getTime();
// }
//}

193
android/app/src/main/java/com/vernu/sms/database/local/SMS.java

@ -0,0 +1,193 @@
//package com.vernu.sms.database.local;
//
//import androidx.annotation.NonNull;
//import androidx.room.ColumnInfo;
//import androidx.room.Entity;
//import androidx.room.PrimaryKey;
//import androidx.room.TypeConverters;
//
//import java.util.Date;
//
//@Entity(tableName = "sms")
//@TypeConverters(DateConverter.class)
//public class SMS {
//
// public SMS() {
// type = null;
// }
//
// @PrimaryKey(autoGenerate = true)
// private int id;
//
// // This is the ID of the SMS in the server
// @ColumnInfo(name = "_id")
// private String _id;
//
// @ColumnInfo(name = "message")
// private String message = "";
//
// @ColumnInfo(name = "encrypted_message")
// private String encryptedMessage = "";
//
// @ColumnInfo(name = "is_encrypted", defaultValue = "0")
// private boolean isEncrypted = false;
//
// @ColumnInfo(name = "sender")
// private String sender;
//
// @ColumnInfo(name = "recipient")
// private String recipient;
//
// @ColumnInfo(name = "requested_at")
// private Date requestedAt;
//
// @ColumnInfo(name = "sent_at")
// private Date sentAt;
//
// @ColumnInfo(name = "delivered_at")
// private Date deliveredAt;
//
// @ColumnInfo(name = "received_at")
// private Date receivedAt;
//
// @NonNull
// @ColumnInfo(name = "type")
// private String type;
//
// @ColumnInfo(name = "server_acknowledged_at")
// private Date serverAcknowledgedAt;
//
// public boolean hasServerAcknowledged() {
// return serverAcknowledgedAt != null;
// }
//
// @ColumnInfo(name = "last_acknowledged_request_at")
// private Date lastAcknowledgedRequestAt;
//
// @ColumnInfo(name = "retry_count", defaultValue = "0")
// private int retryCount = 0;
//
// public int getId() {
// return id;
// }
//
// public void setId(int id) {
// this.id = id;
// }
//
// public String get_id() {
// return _id;
// }
//
// public void set_id(String _id) {
// this._id = _id;
// }
//
// public String getMessage() {
// return message;
// }
//
// public void setMessage(String message) {
// this.message = message;
// }
//
// public String getEncryptedMessage() {
// return encryptedMessage;
// }
//
// public void setEncryptedMessage(String encryptedMessage) {
// this.encryptedMessage = encryptedMessage;
// }
//
// public boolean getIsEncrypted() {
// return isEncrypted;
// }
//
// public void setIsEncrypted(boolean isEncrypted) {
// this.isEncrypted = isEncrypted;
// }
//
// public String getSender() {
// return sender;
// }
//
// public void setSender(String sender) {
// this.sender = sender;
// }
//
// public String getRecipient() {
// return recipient;
// }
//
// public void setRecipient(String recipient) {
// this.recipient = recipient;
// }
//
// public Date getServerAcknowledgedAt() {
// return serverAcknowledgedAt;
// }
//
// public void setServerAcknowledgedAt(Date serverAcknowledgedAt) {
// this.serverAcknowledgedAt = serverAcknowledgedAt;
// }
//
//
//
// public Date getRequestedAt() {
// return requestedAt;
// }
//
// public void setRequestedAt(Date requestedAt) {
// this.requestedAt = requestedAt;
// }
//
// public Date getSentAt() {
// return sentAt;
// }
//
// public void setSentAt(Date sentAt) {
// this.sentAt = sentAt;
// }
//
// public Date getDeliveredAt() {
// return deliveredAt;
// }
//
// public void setDeliveredAt(Date deliveredAt) {
// this.deliveredAt = deliveredAt;
// }
//
// public Date getReceivedAt() {
// return receivedAt;
// }
//
// public void setReceivedAt(Date receivedAt) {
// this.receivedAt = receivedAt;
// }
//
// @NonNull
// public String getType() {
// return type;
// }
//
// public void setType(@NonNull String type) {
// this.type = type;
// }
//
//
// public Date getLastAcknowledgedRequestAt() {
// return lastAcknowledgedRequestAt;
// }
//
// public void setLastAcknowledgedRequestAt(Date lastAcknowledgedRequestAt) {
// this.lastAcknowledgedRequestAt = lastAcknowledgedRequestAt;
// }
//
// public int getRetryCount() {
// return retryCount;
// }
//
// public void setRetryCount(int retryCount) {
// this.retryCount = retryCount;
// }
//}

27
android/app/src/main/java/com/vernu/sms/database/local/SMSDao.java

@ -0,0 +1,27 @@
//package com.vernu.sms.database.local;
//
//import androidx.room.Dao;
//import androidx.room.Delete;
//import androidx.room.Insert;
//import androidx.room.OnConflictStrategy;
//import androidx.room.Query;
//
//import java.util.List;
//
//@Dao
//public interface SMSDao {
//
// @Query("SELECT * FROM sms")
// List<SMS> getAll();
//
// @Query("SELECT * FROM sms WHERE id IN (:smsIds)")
// List<SMS> loadAllByIds(int[] smsIds);
//
// @Insert(onConflict = OnConflictStrategy.REPLACE)
// void insertAll(SMS... sms);
//
//
// @Delete
// void delete(SMS sms);
//
//}

6
android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java

@ -11,7 +11,7 @@ public class RegisterDeviceInputDTO {
private String os;
private String osVersion;
private String appVersionName;
private String appVersionCode;
private int appVersionCode;
public RegisterDeviceInputDTO() {
}
@ -100,11 +100,11 @@ public class RegisterDeviceInputDTO {
this.appVersionName = appVersionName;
}
public String getAppVersionCode() {
public int getAppVersionCode() {
return appVersionCode;
}
public void setAppVersionCode(String appVersionCode) {
public void setAppVersionCode(int appVersionCode) {
this.appVersionCode = appVersionCode;
}
}

42
android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java

@ -0,0 +1,42 @@
package com.vernu.sms.dtos;
import java.util.Date;
public class SMSDTO {
private String sender;
private String message = "";
private Date receivedAt;
public SMSDTO() {
}
public SMSDTO(String sender, String message, Date receivedAt) {
this.sender = sender;
this.message = message;
this.receivedAt = receivedAt;
}
public String getSender() {
return sender;
}
public void setSender(String sender) {
this.sender = sender;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public Date getReceivedAt() {
return receivedAt;
}
public void setReceivedAt(Date receivedAt) {
this.receivedAt = receivedAt;
}
}

9
android/app/src/main/java/com/vernu/sms/dtos/SMSForwardResponseDTO.java

@ -0,0 +1,9 @@
package com.vernu.sms.dtos;
public class SMSForwardResponseDTO {
public SMSForwardResponseDTO() {
}
}

7
android/app/src/main/java/com/vernu/sms/helpers/SharedPreferenceHelper.java

@ -44,4 +44,11 @@ public class SharedPreferenceHelper {
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
return settings.getBoolean(key, defValue);
}
public static void clearSharedPreference(Context context, String key) {
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
SharedPreferences.Editor editor = settings.edit();
editor.remove(key);
editor.apply();
}
}

25
android/app/src/main/java/com/vernu/sms/models/SMSPayload.java

@ -1,27 +1,30 @@
package com.vernu.sms.models;
public class SMSPayload {
private String[] recipients;
private String message;
// Legacy fields that are no longer used
private String[] receivers;
private String smsBody;
public SMSPayload(String[] receivers, String smsBody) {
this.receivers = receivers;
this.smsBody = smsBody;
public SMSPayload() {
}
public String[] getReceivers() {
return receivers;
public String[] getRecipients() {
return recipients;
}
public void setReceivers(String[] receivers) {
this.receivers = receivers;
public void setRecipients(String[] recipients) {
this.recipients = recipients;
}
public String getSmsBody() {
return smsBody;
public String getMessage() {
return message;
}
public void setSmsBody(String smsBody) {
this.smsBody = smsBody;
public void setMessage(String message) {
this.message = message;
}
}

21
android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java

@ -0,0 +1,21 @@
package com.vernu.sms.receivers;
import android.Manifest;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.services.StickyNotificationService;
public class BootCompletedReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
// if(TextBeeUtils.isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)){
// TextBeeUtils.startStickyNotificationService(context);
// }
}
}
}

101
android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java

@ -0,0 +1,101 @@
package com.vernu.sms.receivers;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.provider.Telephony;
import android.telephony.SmsMessage;
import android.util.Log;
import com.vernu.sms.ApiManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.dtos.SMSForwardResponseDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import java.util.Date;
import java.util.Objects;
import retrofit2.Call;
import retrofit2.Response;
public class SMSBroadcastReceiver extends BroadcastReceiver {
private static final String TAG = "SMSBroadcastReceiver";
@Override
public void onReceive(Context context, Intent intent) {
Log.d(TAG, "onReceive: " + intent.getAction());
if (!Objects.equals(intent.getAction(), Telephony.Sms.Intents.SMS_RECEIVED_ACTION)) {
Log.d(TAG, "Not Valid intent");
return;
}
SmsMessage[] messages = Telephony.Sms.Intents.getMessagesFromIntent(intent);
if (messages == null) {
Log.d(TAG, "No messages found");
return;
}
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
boolean receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false);
if (deviceId.isEmpty() || apiKey.isEmpty() || !receiveSMSEnabled) {
Log.d(TAG, "Device ID or API Key is empty or Receive SMS Feature is disabled");
return;
}
// SMS receivedSMS = new SMS();
// receivedSMS.setType("RECEIVED");
// for (SmsMessage message : messages) {
// receivedSMS.setMessage(receivedSMS.getMessage() + message.getMessageBody());
// receivedSMS.setSender(message.getOriginatingAddress());
// receivedSMS.setReceivedAt(new Date(message.getTimestampMillis()));
// }
SMSDTO receivedSMSDTO = new SMSDTO();
for (SmsMessage message : messages) {
receivedSMSDTO.setMessage(receivedSMSDTO.getMessage() + message.getMessageBody());
receivedSMSDTO.setSender(message.getOriginatingAddress());
receivedSMSDTO.setReceivedAt(new Date(message.getTimestampMillis()));
}
// receivedSMSDTO.setSender(receivedSMS.getSender());
// receivedSMSDTO.setMessage(receivedSMS.getMessage());
// receivedSMSDTO.setReceivedAt(receivedSMS.getReceivedAt());
Call<SMSForwardResponseDTO> apiCall = ApiManager.getApiService().sendReceivedSMS(deviceId, apiKey, receivedSMSDTO);
apiCall.enqueue(new retrofit2.Callback<SMSForwardResponseDTO>() {
@Override
public void onResponse(Call<SMSForwardResponseDTO> call, Response<SMSForwardResponseDTO> response) {
// Date now = new Date();
if (response.isSuccessful()) {
Log.d(TAG, "SMS sent to server successfully");
// receivedSMS.setLastAcknowledgedRequestAt(now);
// receivedSMS.setServerAcknowledgedAt(now);
// updateLocalReceivedSMS(receivedSMS, context);
} else {
Log.e(TAG, "Failed to send SMS to server");
// receivedSMS.setServerAcknowledgedAt(null);
// receivedSMS.setLastAcknowledgedRequestAt(now);
// receivedSMS.setRetryCount(localReceivedSMS.getRetryCount() + 1);
// updateLocalReceivedSMS(receivedSMS, context);
}
}
@Override
public void onFailure(Call<SMSForwardResponseDTO> call, Throwable t) {
Log.e(TAG, "Failed to send SMS to server", t);
// receivedSMS.setServerAcknowledgedAt(null);
// receivedSMS.setLastAcknowledgedRequestAt(new Date());
// updateLocalReceivedSMS(receivedSMS, context);
}
});
}
// private void updateLocalReceivedSMS(SMS localReceivedSMS, Context context) {
// Executors.newSingleThreadExecutor().execute(() -> {
// AppDatabase appDatabase = AppDatabase.getInstance(context);
// appDatabase.localReceivedSMSDao().insertAll(localReceivedSMS);
// });
// }
}

16
android/app/src/main/java/com/vernu/sms/services/FCMService.java

@ -9,42 +9,40 @@ import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
import com.google.gson.Gson;
import com.vernu.sms.AppConstants;
import com.vernu.sms.R;
import com.vernu.sms.activities.MainActivity;
import com.vernu.sms.helpers.SMSHelper;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.models.SMSPayload;
public class FCMService extends FirebaseMessagingService {
private static final String TAG = "MyFirebaseMsgService";
private static final String TAG = "FirebaseMessagingService";
private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "N1";
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
Log.d("FCM_MESSAGE", remoteMessage.getData().toString());
Log.d(TAG, remoteMessage.getData().toString());
Gson gson = new Gson();
SMSPayload smsPayload = gson.fromJson(remoteMessage.getData().get("smsData"), SMSPayload.class);
// Check if message contains a data payload.
if (remoteMessage.getData().size() > 0) {
int preferedSim = SharedPreferenceHelper.getSharedPreferenceInt(this, "PREFERED_SIM", -1);
for (String receiver : smsPayload.getReceivers()) {
int preferedSim = SharedPreferenceHelper.getSharedPreferenceInt(this, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
for (String receiver : smsPayload.getRecipients()) {
if(preferedSim == -1) {
SMSHelper.sendSMS(receiver, smsPayload.getSmsBody());
SMSHelper.sendSMS(receiver, smsPayload.getMessage());
continue;
}
try {
SMSHelper.sendSMSFromSpecificSim(receiver, smsPayload.getSmsBody(), preferedSim);
SMSHelper.sendSMSFromSpecificSim(receiver, smsPayload.getMessage(), preferedSim);
} catch(Exception e) {
Log.d("SMS_SEND_ERROR", e.getMessage());
}

11
android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java

@ -1,19 +1,24 @@
package com.vernu.sms.services;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.dtos.SMSForwardResponseDTO;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.Header;
import retrofit2.http.PATCH;
import retrofit2.http.POST;
import retrofit2.http.Path;
import retrofit2.http.Query;
public interface GatewayApiService {
@POST("gateway/devices")
Call<RegisterDeviceResponseDTO> registerDevice(@Query("apiKey") String apiKey, @Body() RegisterDeviceInputDTO body);
Call<RegisterDeviceResponseDTO> registerDevice(@Header("x-api-key") String apiKey, @Body() RegisterDeviceInputDTO body);
@PATCH("gateway/devices/{deviceId}")
Call<RegisterDeviceResponseDTO> updateDevice(@Path("deviceId") String deviceId, @Query("apiKey") String apiKey, @Body() RegisterDeviceInputDTO body);
Call<RegisterDeviceResponseDTO> updateDevice(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() RegisterDeviceInputDTO body);
@POST("gateway/devices/{deviceId}/receiveSMS")
Call<SMSForwardResponseDTO> sendReceivedSMS(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
}

82
android/app/src/main/java/com/vernu/sms/services/StickyNotificationService.java

@ -0,0 +1,82 @@
package com.vernu.sms.services;
import android.app.*;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.IBinder;
import android.provider.Telephony;
import android.util.Log;
import android.widget.Toast;
import androidx.core.app.NotificationCompat;
import com.vernu.sms.R;
import com.vernu.sms.activities.MainActivity;
import com.vernu.sms.receivers.SMSBroadcastReceiver;
public class StickyNotificationService extends Service {
private static final String TAG = "StickyNotificationService";
private final BroadcastReceiver receiver = new SMSBroadcastReceiver();
@Override
public IBinder onBind(Intent intent) {
Log.i(TAG, "Service onBind " + intent.getAction());
return null;
}
@Override
public void onCreate() {
super.onCreate();
Log.i(TAG, "Service Started");
// IntentFilter filter = new IntentFilter();
// filter.addAction(Telephony.Sms.Intents.SMS_RECEIVED_ACTION);
// filter.addAction(android.telephony.TelephonyManager.ACTION_PHONE_STATE_CHANGED);
// registerReceiver(receiver, filter);
//
// Notification notification = createNotification();
// startForeground(1, notification);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.i(TAG, "Received start id " + startId + ": " + intent);
return START_STICKY;
}
@Override
public void onDestroy() {
super.onDestroy();
// unregisterReceiver(receiver);
Log.i(TAG, "StickyNotificationService destroyed");
// Toast.makeText(this, "Service destroyed", Toast.LENGTH_SHORT).show();
}
private Notification createNotification() {
String notificationChannelId = "stickyNotificationChannel";
NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
NotificationChannel channel = null;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
channel = new NotificationChannel(notificationChannelId, notificationChannelId, NotificationManager.IMPORTANCE_HIGH);
channel.enableVibration(false);
channel.setShowBadge(false);
notificationManager.createNotificationChannel(channel);
Intent notificationIntent = new Intent(this, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
Notification.Builder builder = new Notification.Builder(this, notificationChannelId);
return builder.setContentTitle("TextBee is running").setContentText("TextBee is running in the background.").setContentIntent(pendingIntent).setOngoing(true).setSmallIcon(R.drawable.ic_launcher_foreground).build();
} else {
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, notificationChannelId);
return builder.setContentTitle("TextBee is running").setContentText("TextBee is running in the background.").setOngoing(true).setSmallIcon(R.drawable.ic_launcher_foreground).build();
}
}
}

184
android/app/src/main/res/layout/activity_main.xml

@ -1,82 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".activities.MainActivity">
<LinearLayout
android:id="@+id/bottom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#ccccccee"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:padding="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="How To Use"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Go to textbee.dev/dashboard and click register device, then copy and paste the api key generated or scan the QR code" />
<Button
android:id="@+id/grantSMSPermissionBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Grant SMS Permission"
android:visibility="visible" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Default SIM"
android:textStyle="bold" />
<RadioGroup
android:id="@+id/defaultSimSlotRadioGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
</RadioGroup>
</LinearLayout>
</LinearLayout>
<ScrollView
android:id="@+id/scrollView2"
android:layout_width="match_parent"
android:layout_height="606dp"
app:layout_constraintBottom_toTopOf="@+id/bottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0">
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enter your API key or scan the QR code below to get started"
android:textSize="20dp"
android:layout_margin="5dp"
android:textAlignment="center"
android:layout_gravity="center" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#ccccccee"
android:layout_margin="5dp"
android:orientation="horizontal">
<LinearLayout
@ -91,7 +46,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:hint="key"
android:hint="API Key"
android:inputType="text"
android:minHeight="48dp"
android:textIsSelectable="true" />
@ -137,6 +92,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_margin="5dp"
android:orientation="horizontal">
<LinearLayout
@ -229,9 +185,119 @@
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:orientation="vertical"
android:padding="10px">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Configuration"
android:textSize="18sp"
android:textStyle="bold" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="#000000" />
<Button
android:id="@+id/grantSMSPermissionBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Grant Permissions"
android:visibility="visible" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="TextBee will only work if you grant SMS Permissions"
android:textSize="14dp"
android:textStyle="italic" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#000000" />
<Switch
android:id="@+id/receiveSMSSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:minHeight="32dp"
android:text="Receive SMS" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Toggle this if you want to receive SMS"
android:textSize="14dp"
android:textStyle="italic" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#000000" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Default SIM"
android:textStyle="bold" />
<RadioGroup
android:id="@+id/defaultSimSlotRadioGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"></RadioGroup>
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Select your preferred SIM for sending SMS"
android:textSize="14dp"
android:textStyle="italic" />
</LinearLayout>
<LinearLayout
android:id="@+id/bottom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#ccccccee"
android:orientation="vertical"
android:layout_marginTop="30dp"
android:padding="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="How To Use"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Go to textbee.dev/dashboard and click `generate API Key / Get started`, then copy and paste the API key generated or scan the QR code" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

1
api/.env.example

@ -1,6 +1,7 @@
PORT=
MONGO_URI=
JWT_SECRET=secret
JWT_EXPIRATION=60d
FIREBASE_PROJECT_ID=
FIREBASE_PRIVATE_KEY_ID=

17
api/src/auth/auth.controller.ts

@ -27,6 +27,7 @@ export class AuthController {
constructor(private authService: AuthService) {}
@ApiOperation({ summary: 'Login' })
@HttpCode(HttpStatus.OK)
@Post('/login')
async login(@Body() input: LoginInputDTO) {
const data = await this.authService.login(input)
@ -34,6 +35,7 @@ export class AuthController {
}
@ApiOperation({ summary: 'Login With Google' })
@HttpCode(HttpStatus.OK)
@Post('/google-login')
async googleLogin(@Body() input: any) {
const data = await this.authService.loginWithGoogle(input.idToken)
@ -48,11 +50,6 @@ export class AuthController {
}
@ApiOperation({ summary: 'Get current logged in user' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@Get('/who-am-i')
@ -62,11 +59,6 @@ export class AuthController {
@UseGuards(AuthGuard)
@ApiOperation({ summary: 'Generate Api Key' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@ApiBearerAuth()
@Post('/api-keys')
async generateApiKey(@Request() req) {
@ -76,11 +68,6 @@ export class AuthController {
@UseGuards(AuthGuard)
@ApiOperation({ summary: 'Get Api Key List (masked***)' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@ApiBearerAuth()
@Get('/api-keys')
async getApiKey(@Request() req) {

7
api/src/auth/auth.module.ts

@ -12,6 +12,7 @@ import {
PasswordReset,
PasswordResetSchema,
} from './schemas/password-reset.schema'
import { AccessLog, AccessLogSchema } from './schemas/access-log.schema'
@Module({
imports: [
@ -24,12 +25,16 @@ import {
name: PasswordReset.name,
schema: PasswordResetSchema,
},
{
name: AccessLog.name,
schema: AccessLogSchema,
},
]),
UsersModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '180d' },
signOptions: { expiresIn: process.env.JWT_EXPIRATION || '60d' },
}),
MailModule,
],

37
api/src/auth/auth.service.ts

@ -14,6 +14,7 @@ import {
} from './schemas/password-reset.schema'
import { MailService } from 'src/mail/mail.service'
import { RequestResetPasswordInputDTO, ResetPasswordInputDTO } from './auth.dto'
import { AccessLog } from './schemas/access-log.schema'
@Injectable()
export class AuthService {
constructor(
@ -22,6 +23,7 @@ export class AuthService {
@InjectModel(ApiKey.name) private apiKeyModel: Model<ApiKeyDocument>,
@InjectModel(PasswordReset.name)
private passwordResetModel: Model<PasswordResetDocument>,
@InjectModel(AccessLog.name) private accessLogModel: Model<AccessLog>,
private readonly mailService: MailService,
) {}
@ -194,6 +196,39 @@ export class AuthService {
)
}
await this.apiKeyModel.deleteOne({ _id: apiKeyId })
// await this.apiKeyModel.deleteOne({ _id: apiKeyId })
}
async trackAccessLog({ request }) {
const { apiKey, user, method, url, ip, headers } = request
const userAgent = headers['user-agent']
if (request.apiKey) {
this.apiKeyModel
.findByIdAndUpdate(
apiKey._id,
{ $inc: { usageCount: 1 }, lastUsedAt: new Date() },
{ new: true },
)
.exec()
.catch((e) => {
console.log('Failed to update api key usage count')
console.log(e)
})
}
this.accessLogModel
.create({
apiKey,
user,
method,
url: url.split('?')[0],
ip,
userAgent,
})
.catch((e) => {
console.log('Failed to track access log')
console.log(e)
})
}
}

21
api/src/auth/guards/auth.guard.ts

@ -20,25 +20,29 @@ export class AuthGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
var userId
const request = context.switchToHttp().getRequest()
let userId
const apiKeyString = request.headers['x-api-key'] || request.query.apiKey
if (request.headers.authorization?.startsWith('Bearer ')) {
const bearerToken = request.headers.authorization.split(' ')[1]
try {
const payload = this.jwtService.verify(bearerToken)
userId = payload.sub
} catch (e) {
throw new HttpException(
{ error: 'Unauthorized' },
HttpStatus.UNAUTHORIZED,
)
}
// check apiKey in query params
else if (request.query.apiKey) {
const apiKeyStr = request.query.apiKey
const regex = new RegExp(`^${apiKeyStr.substr(0, 17)}`, 'g')
} else if (apiKeyString) {
const regex = new RegExp(`^${apiKeyString.substr(0, 17)}`, 'g')
const apiKey = await this.authService.findApiKey({
apiKey: { $regex: regex },
})
if (apiKey && bcrypt.compareSync(apiKeyStr, apiKey.hashedApiKey)) {
if (apiKey && bcrypt.compareSync(apiKeyString, apiKey.hashedApiKey)) {
userId = apiKey.user
request.apiKey = apiKey
}
}
@ -46,6 +50,7 @@ export class AuthGuard implements CanActivate {
const user = await this.usersService.findOne({ _id: userId })
if (user) {
request.user = user
this.authService.trackAccessLog({ request })
return true
}
}

31
api/src/auth/schemas/access-log.schema.ts

@ -0,0 +1,31 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
import { User } from '../../users/schemas/user.schema'
import { ApiKey } from './api-key.schema'
export type AccessLogDocument = AccessLog & Document
@Schema({ timestamps: true })
export class AccessLog {
_id?: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: ApiKey.name })
apiKey: ApiKey
@Prop({ type: Types.ObjectId, ref: User.name })
user: User
@Prop({ type: String })
url: string
@Prop({ type: String })
method: string
@Prop({ type: String })
ip: string
@Prop({ type: String })
userAgent: string
}
export const AccessLogSchema = SchemaFactory.createForClass(AccessLog)

6
api/src/auth/schemas/api-key.schema.ts

@ -16,6 +16,12 @@ export class ApiKey {
@Prop({ type: Types.ObjectId, ref: User.name })
user: User
@Prop({ type: Number, default: 0 })
usageCount: number
@Prop({ type: Date })
lastUsedAt: Date
}
export const ApiKeySchema = SchemaFactory.createForClass(ApiKey)

62
api/src/gateway/gateway.controller.ts

@ -8,10 +8,23 @@ import {
Request,
Get,
Delete,
HttpCode,
HttpStatus,
} from '@nestjs/common'
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger'
import { AuthGuard } from '../auth/guards/auth.guard'
import { RegisterDeviceInputDTO, SendSMSInputDTO } from './gateway.dto'
import {
ReceivedSMSDTO,
RegisterDeviceInputDTO,
RetrieveSMSResponseDTO,
SendSMSInputDTO,
} from './gateway.dto'
import { GatewayService } from './gateway.service'
import { CanModifyDevice } from './guards/can-modify-device.guard'
@ -30,11 +43,6 @@ export class GatewayController {
@UseGuards(AuthGuard)
@ApiOperation({ summary: 'Register device' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@Post('/devices')
async registerDevice(@Body() input: RegisterDeviceInputDTO, @Request() req) {
const data = await this.gatewayService.registerDevice(input, req.user)
@ -43,11 +51,6 @@ export class GatewayController {
@UseGuards(AuthGuard)
@ApiOperation({ summary: 'List of registered devices' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@Get('/devices')
async getDevices(@Request() req) {
const data = await this.gatewayService.getDevicesForUser(req.user)
@ -55,11 +58,6 @@ export class GatewayController {
}
@ApiOperation({ summary: 'Update device' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@UseGuards(AuthGuard, CanModifyDevice)
@Patch('/devices/:id')
async updateDevice(
@ -71,11 +69,6 @@ export class GatewayController {
}
@ApiOperation({ summary: 'Delete device' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@UseGuards(AuthGuard, CanModifyDevice)
@Delete('/devices/:id')
async deleteDevice(@Param('id') deviceId: string) {
@ -84,11 +77,6 @@ export class GatewayController {
}
@ApiOperation({ summary: 'Send SMS to a device' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@UseGuards(AuthGuard, CanModifyDevice)
@Post('/devices/:id/sendSMS')
async sendSMS(
@ -98,4 +86,24 @@ export class GatewayController {
const data = await this.gatewayService.sendSMS(deviceId, smsData)
return { data }
}
@ApiOperation({ summary: 'Received SMS from a device' })
@HttpCode(HttpStatus.OK)
@Post('/devices/:id/receiveSMS')
@UseGuards(AuthGuard, CanModifyDevice)
async receiveSMS(@Param('id') deviceId: string, @Body() dto: ReceivedSMSDTO) {
const data = await this.gatewayService.receiveSMS(deviceId, dto)
return { data }
}
@ApiOperation({ summary: 'Get received SMS from a device' })
@ApiResponse({ status: 200, type: RetrieveSMSResponseDTO })
@UseGuards(AuthGuard, CanModifyDevice)
@Get('/devices/:id/getReceivedSMS')
async getReceivedSMS(
@Param('id') deviceId: string,
): Promise<RetrieveSMSResponseDTO> {
const data = await this.gatewayService.getReceivedSMS(deviceId)
return { data }
}
}

134
api/src/gateway/gateway.dto.ts

@ -39,16 +39,144 @@ export class SMSData {
@ApiProperty({
type: String,
required: true,
description: 'SMS text',
description: 'The message to send',
})
smsBody: string
message: string
@ApiProperty({
type: Array,
required: true,
description: 'Array of phone numbers',
description: 'List of phone numbers to send the SMS to',
example: ['+2519xxxxxxxx', '+2517xxxxxxxx'],
})
recipients: string[]
// TODO: restructure the Payload such that it contains bactchId, smsId, recipients and message in an optimized way
// message: string
// bactchId: string
// list: {
// smsId: string
// recipient: string
// }
// Legacy fields to be removed in the future
// @ApiProperty({
// type: String,
// required: true,
// description: '(Legacy) Will be Replace with `message` field in the future',
// })
smsBody: string
// @ApiProperty({
// type: Array,
// required: false,
// description:
// '(Legacy) Will be Replace with `recipients` field in the future',
// example: ['+2519xxxxxxxx', '+2517xxxxxxxx'],
// })
receivers: string[]
}
export class SendSMSInputDTO extends SMSData {}
export class ReceivedSMSDTO {
@ApiProperty({
type: String,
required: true,
description: 'The message received',
})
message: string
@ApiProperty({
type: String,
required: true,
description: 'The phone number of the sender',
})
sender: string
@ApiProperty({
type: Date,
required: true,
description: 'The time the message was received',
})
receivedAt: Date
}
export class DeviceDTO {
@ApiProperty({ type: String })
_id: string
@ApiProperty({ type: Boolean })
enabled: boolean
@ApiProperty({ type: String })
brand: string
@ApiProperty({ type: String })
manufacturer: string
@ApiProperty({ type: String })
model: string
@ApiProperty({ type: String })
buildId: string
}
export class RetrieveSMSDTO {
@ApiProperty({
type: String,
required: true,
description: 'The id of the received SMS',
})
_id: string
@ApiProperty({
type: String,
required: true,
description: 'The message received',
})
message: string
@ApiProperty({
type: DeviceDTO,
required: true,
description: 'The device that received the message',
})
device: DeviceDTO
@ApiProperty({
type: String,
required: true,
description: 'The phone number of the sender',
})
sender: string
@ApiProperty({
type: Date,
required: true,
description: 'The time the message was received',
})
receivedAt: Date
@ApiProperty({
type: Date,
required: true,
description: 'The time the message was created',
})
createdAt: Date
@ApiProperty({
type: Date,
required: true,
description: 'The time the message was last updated',
})
updatedAt: Date
}
export class RetrieveSMSResponseDTO {
@ApiProperty({
type: [RetrieveSMSDTO],
required: true,
description: 'The received SMS data',
})
data: RetrieveSMSDTO[]
}

5
api/src/gateway/gateway.module.ts

@ -5,6 +5,7 @@ import { GatewayController } from './gateway.controller'
import { GatewayService } from './gateway.service'
import { AuthModule } from '../auth/auth.module'
import { UsersModule } from '../users/users.module'
import { SMS, SMSSchema } from './schemas/sms.schema'
@Module({
imports: [
@ -13,6 +14,10 @@ import { UsersModule } from '../users/users.module'
name: Device.name,
schema: DeviceSchema,
},
{
name: SMS.name,
schema: SMSSchema,
},
]),
AuthModule,
UsersModule,

112
api/src/gateway/gateway.service.ts

@ -3,13 +3,21 @@ import { InjectModel } from '@nestjs/mongoose'
import { Device, DeviceDocument } from './schemas/device.schema'
import { Model } from 'mongoose'
import * as firebaseAdmin from 'firebase-admin'
import { RegisterDeviceInputDTO, SendSMSInputDTO } from './gateway.dto'
import {
ReceivedSMSDTO,
RegisterDeviceInputDTO,
RetrieveSMSDTO,
SendSMSInputDTO,
} from './gateway.dto'
import { User } from '../users/schemas/user.schema'
import { AuthService } from 'src/auth/auth.service'
import { SMS } from './schemas/sms.schema'
import { SMSType } from './sms-type.enum'
@Injectable()
export class GatewayService {
constructor(
@InjectModel(Device.name) private deviceModel: Model<DeviceDocument>,
@InjectModel(SMS.name) private smsModel: Model<SMS>,
private authService: AuthService,
) {}
@ -72,10 +80,19 @@ export class GatewayService {
)
}
return await this.deviceModel.findByIdAndDelete(deviceId)
return {}
// return await this.deviceModel.findByIdAndDelete(deviceId)
}
async sendSMS(deviceId: string, smsData: SendSMSInputDTO): Promise<any> {
const updatedSMSData = {
message: smsData.message || smsData.smsBody,
recipients: smsData.recipients || smsData.receivers,
// Legacy fields to be removed in the future
smsBody: smsData.message || smsData.smsBody,
receivers: smsData.recipients || smsData.receivers,
}
const device = await this.deviceModel.findById(deviceId)
if (!device?.enabled) {
@ -88,11 +105,15 @@ export class GatewayService {
)
}
const stringifiedSMSData = JSON.stringify(updatedSMSData)
const payload: any = {
data: {
smsData: JSON.stringify(smsData),
smsData: stringifiedSMSData,
},
}
// TODO: Save SMS and Implement a queue to send the SMS if recipients are too many
try {
const response = await firebaseAdmin
.messaging()
@ -100,7 +121,7 @@ export class GatewayService {
this.deviceModel
.findByIdAndUpdate(deviceId, {
$inc: { sentSMSCount: smsData.receivers.length },
$inc: { sentSMSCount: updatedSMSData.recipients.length },
})
.exec()
.catch((e) => {
@ -118,19 +139,98 @@ export class GatewayService {
}
}
async receiveSMS(deviceId: string, dto: ReceivedSMSDTO): Promise<any> {
const device = await this.deviceModel.findById(deviceId)
if (!device) {
throw new HttpException(
{
success: false,
error: 'Device does not exist',
},
HttpStatus.BAD_REQUEST,
)
}
if (!dto.receivedAt || !dto.sender || !dto.message) {
throw new HttpException(
{
success: false,
error: 'Invalid received SMS data',
},
HttpStatus.BAD_REQUEST,
)
}
const sms = await this.smsModel.create({
device: device._id,
message: dto.message,
type: SMSType.RECEIVED,
sender: dto.sender,
receivedAt: dto.receivedAt,
})
this.deviceModel
.findByIdAndUpdate(deviceId, {
$inc: { receivedSMSCount: 1 },
})
.exec()
.catch((e) => {
console.log('Failed to update receivedSMSCount')
console.log(e)
})
// TODO: Implement webhook to forward received SMS to user's callback URL
return sms
}
async getReceivedSMS(deviceId: string): Promise<RetrieveSMSDTO[]> {
const device = await this.deviceModel.findById(deviceId)
if (!device) {
throw new HttpException(
{
success: false,
error: 'Device does not exist',
},
HttpStatus.BAD_REQUEST,
)
}
return await this.smsModel
.find(
{
device: device._id,
type: SMSType.RECEIVED,
},
null,
{ sort: { receivedAt: -1 }, limit: 200 },
)
.populate({
path: 'device',
select: '_id brand model buildId enabled',
})
}
async getStatsForUser(user: User) {
const devices = await this.deviceModel.find({ user: user._id })
const apiKeys = await this.authService.getUserApiKeys(user)
const totalSMSCount = devices.reduce((acc, device) => {
const totalSentSMSCount = devices.reduce((acc, device) => {
return acc + (device.sentSMSCount || 0)
}, 0)
const totalReceivedSMSCount = devices.reduce((acc, device) => {
return acc + (device.receivedSMSCount || 0)
}, 0)
const totalDeviceCount = devices.length
const totalApiKeyCount = apiKeys.length
return {
totalSMSCount,
totalSentSMSCount,
totalReceivedSMSCount,
totalDeviceCount,
totalApiKeyCount,
}

3
api/src/gateway/schemas/device.schema.ts

@ -46,6 +46,9 @@ export class Device {
@Prop({ type: Number, default: 0 })
sentSMSCount: number
@Prop({ type: Number, default: 0 })
receivedSMSCount: number
}
export const DeviceSchema = SchemaFactory.createForClass(Device)

45
api/src/gateway/schemas/sms.schema.ts

@ -8,14 +8,53 @@ export type SMSDocument = SMS & Document
export class SMS {
_id?: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: Device.name })
@Prop({ type: Types.ObjectId, ref: Device.name, required: true })
device: Device
@Prop({ type: String, required: true })
@Prop({ type: String })
message: string
@Prop({ type: Boolean, default: false })
encrypted: boolean
@Prop({ type: String })
encryptedMessage: string
@Prop({ type: String, required: true })
to: string
type: string
// fields for incoming messages
@Prop({ type: String })
sender: string
@Prop({ type: Date })
receivedAt: Date
// fields for outgoing messages
@Prop({ type: String })
recipient: string
@Prop({ type: Date })
requestedAt: Date
@Prop({ type: Date })
sentAt: Date
@Prop({ type: Date })
deliveredAt: Date
@Prop({ type: Date })
failedAt: Date
// @Prop({ type: String })
// failureReason: string
// @Prop({ type: String })
// status: string
// misc metadata for debugging
@Prop({ type: Object })
metadata: Record<string, any>
}
export const SMSSchema = SchemaFactory.createForClass(SMS)

4
api/src/gateway/sms-type.enum.ts

@ -0,0 +1,4 @@
export enum SMSType {
SENT = 'SENT',
RECEIVED = 'RECEIVED',
}

5
api/src/main.ts

@ -20,6 +20,11 @@ async function bootstrap() {
.setDescription('TextBee - Android SMS Gateway API Docs')
.setVersion('1.0')
.addBearerAuth()
.addApiKey({
type: 'apiKey',
name: 'x-api-key',
in: 'header',
})
.build()
const document = SwaggerModule.createDocument(app, config)
SwaggerModule.setup('', app, document, {

2
web/components/Footer.tsx

@ -25,7 +25,7 @@ export default function Footer() {
<Stack direction={'row'} spacing={6}>
<Link href='/'>Home</Link>
<Link href='/dashboard'>Dashboard</Link>
<Link href='/android'>Download App</Link>
<Link href='https://dl.textbee.dev' target='_blank'> Download App</Link>
<Link href='https://github.com/vernu/textbee'>Github</Link>
</Stack>
</Container>

18
web/components/Navbar.tsx

@ -19,12 +19,30 @@ import Router from 'next/router'
import { useDispatch, useSelector } from 'react-redux'
import { logout, selectAuthUser } from '../store/authSlice'
import Image from 'next/image'
import { useEffect } from 'react'
import { authService } from '../services/authService'
export default function Navbar() {
const dispatch = useDispatch()
const { colorMode, toggleColorMode } = useColorMode()
const authUser = useSelector(selectAuthUser)
useEffect(() => {
const timout = setTimeout(async () => {
if (authUser) {
authService
.whoAmI()
.catch((e) => {
if (e.response?.status === 401) {
dispatch(logout())
}
})
.then((res) => {})
}
}, 5000)
return () => clearTimeout(timout)
}, [authUser, dispatch])
return (
<>
<Box

29
web/components/dashboard/APIKeyAndDevices.tsx

@ -0,0 +1,29 @@
import { Box, SimpleGrid } from '@chakra-ui/react'
import React from 'react'
import ErrorBoundary from '../ErrorBoundary'
import ApiKeyList from './ApiKeyList'
import DeviceList from './DeviceList'
import GenerateApiKey from './GenerateApiKey'
export default function APIKeyAndDevices() {
return (
<Box backdropBlur='2xl' borderWidth='0px' borderRadius='lg'>
<Box maxW='xl' mx={'auto'} pt={5} px={{ base: 2, sm: 12, md: 17 }}>
<GenerateApiKey />
</Box>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={{ base: 5, lg: 8 }}>
<Box backdropBlur='2xl' borderWidth='0px' borderRadius='lg'>
<ErrorBoundary>
<ApiKeyList />
</ErrorBoundary>
</Box>
<Box backdropBlur='2xl' borderWidth='0px' borderRadius='lg'>
<ErrorBoundary>
<DeviceList />
</ErrorBoundary>
</Box>
</SimpleGrid>
</Box>
)
}

2
web/components/dashboard/ApiKeyList.tsx

@ -57,7 +57,7 @@ const ApiKeyList = () => {
return (
<TableContainer>
<Table variant='simple'>
<Table variant='striped'>
<Thead>
<Tr>
<Th>Your API Keys</Th>

2
web/components/dashboard/DeviceList.tsx

@ -76,7 +76,7 @@ const DeviceList = () => {
return (
<TableContainer>
<Table variant='simple'>
<Table variant='striped'>
<Thead>
<Tr>
<Th>Your Devices</Th>

170
web/components/dashboard/ReceiveSMS.tsx

@ -0,0 +1,170 @@
import {
Alert,
AlertIcon,
Grid,
GridItem,
Spinner,
Stack,
Tab,
TabList,
TabPanel,
TabPanels,
Table,
TableContainer,
Tabs,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@chakra-ui/react'
import { useEffect, useMemo, useState } from 'react'
import { useSelector } from 'react-redux'
import {
fetchReceivedSMSList,
selectDeviceList,
selectReceivedSMSList,
} from '../../store/deviceSlice'
import { useAppDispatch } from '../../store/hooks'
import { selectAuthUser } from '../../store/authSlice'
export default function ReceiveSMS() {
return (
<>
<Grid
templateColumns={{ base: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' }}
gap={6}
>
<GridItem colSpan={2}>
<ReceivedSMSList />
</GridItem>
<GridItem colSpan={1}>
<ReceiveSMSNotifications />
</GridItem>
</Grid>
</>
)
}
const ReceiveSMSNotifications = () => {
return (
<Stack spacing={3}>
<Alert status='success'>
<AlertIcon />
You can now receive SMS and view them in the dashboard, or retreive them
via the API
</Alert>
<Alert status='warning'>
<AlertIcon />
To receive SMS, you need to have an active device that has receive SMS
option enabled <small>(Turn on the switch in the app)</small>
</Alert>
<Alert status='info'>
<AlertIcon />
Webhooks will be available soon 😉
</Alert>
</Stack>
)
}
const ReceivedSMSList = () => {
const dispatch = useAppDispatch()
const [tabIndex, setTabIndex] = useState(0)
const { loading: receivedSMSListLoading, data: receivedSMSListData } =
useSelector(selectReceivedSMSList)
const deviceList = useSelector(selectDeviceList)
const authUser = useSelector(selectAuthUser)
const activeDeviceId = useMemo(() => {
return deviceList[tabIndex]?._id
}, [tabIndex, deviceList])
useEffect(() => {
if (authUser && activeDeviceId) {
dispatch(fetchReceivedSMSList(activeDeviceId))
}
}, [dispatch, authUser, activeDeviceId])
if (!receivedSMSListLoading && (!deviceList || deviceList.length == 0)) {
return (
<Alert status='warning'>
<AlertIcon />
You dont have any devices yet. Please register a device to receive SMS
</Alert>
)
}
return (
<>
<Tabs isLazy={false} index={tabIndex} onChange={setTabIndex}>
<TabList>
{deviceList.map(({ _id, brand, model }) => (
<Tab key={_id}>{`${brand} ${model}`}</Tab>
))}
</TabList>
<TabPanels>
{deviceList.map(({ _id, brand, model }) => (
<TabPanel key={_id}>
<TableContainer>
<Table variant='striped'>
<Thead>
<Tr>
<Th>sender</Th>
<Th colSpan={4}>message</Th>
<Th>received at</Th>
</Tr>
</Thead>
<Tbody>
{receivedSMSListLoading && (
<Tr>
<Td colSpan={6} textAlign='center'>
<Spinner size='lg' />
</Td>
</Tr>
)}
{!receivedSMSListLoading &&
receivedSMSListData.length == 0 && (
<Td colSpan={6} textAlign='center'>
No SMS received
</Td>
)}
{!receivedSMSListLoading &&
receivedSMSListData.length > 0 &&
receivedSMSListData.map(
({ _id, sender, message, receivedAt }) => (
<Tr key={_id}>
<Td>{sender}</Td>
<Td whiteSpace='pre-wrap' colSpan={4}>
{message}
</Td>
<Td>
{new Date(receivedAt).toLocaleString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
})}
</Td>
<Td></Td>
</Tr>
)
)}
</Tbody>
</Table>
</TableContainer>
</TabPanel>
))}
</TabPanels>
</Tabs>
</>
)
}

77
web/components/dashboard/SendSMS.tsx

@ -1,20 +1,12 @@
import {
Box,
Button,
Flex,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
SimpleGrid,
Spinner,
Textarea,
useDisclosure,
useToast,
} from '@chakra-ui/react'
import { useState } from 'react'
@ -39,29 +31,33 @@ export const SendSMSForm = ({ deviceList, formData, handleChange }) => {
value={formData.device}
>
{deviceList.map((device) => (
<option key={device._id} value={device._id}>
<option
key={device._id}
value={device._id}
disabled={!device.enabled}
>
{device.model}
</option>
))}
</Select>
</Box>
<Box>
<FormLabel htmlFor='receivers'>Receiver</FormLabel>
<FormLabel htmlFor='recipient'>Recipient</FormLabel>
<Input
placeholder='receiver'
name='receivers'
placeholder='recipient'
name='recipients'
onChange={handleChange}
value={formData.receivers}
value={formData.recipients}
type='tel'
/>
</Box>
<Box>
<FormLabel htmlFor='smsBody'>SMS Body</FormLabel>
<FormLabel htmlFor='message'>Message</FormLabel>
<Textarea
id='smsBody'
name='smsBody'
id='message'
name='message'
onChange={handleChange}
value={formData.smsBody}
value={formData.message}
/>
</Box>
</>
@ -69,7 +65,6 @@ export const SendSMSForm = ({ deviceList, formData, handleChange }) => {
}
export default function SendSMS() {
const { isOpen, onOpen, onClose } = useDisclosure()
const deviceList = useSelector(selectDeviceList)
const toast = useToast()
const dispatch = useAppDispatch()
@ -78,16 +73,16 @@ export default function SendSMS() {
const [formData, setFormData] = useState({
device: '',
receivers: '',
smsBody: '',
recipients: '',
message: '',
})
const handSend = (e) => {
e.preventDefault()
const { device: deviceId, receivers, smsBody } = formData
const receiversArray = receivers.replace(' ', '').split(',')
const { device: deviceId, recipients, message } = formData
const recipientsArray = recipients.replace(' ', '').split(',')
if (!deviceId || !receivers || !smsBody) {
if (!deviceId || !recipients || !message) {
toast({
title: 'Please fill all fields',
status: 'error',
@ -95,17 +90,16 @@ export default function SendSMS() {
return
}
for (let receiver of receiversArray) {
for (let recipient of recipientsArray) {
// TODO: validate phone numbers
}
dispatch(
sendSMS({
deviceId,
payload: {
receivers: receiversArray,
smsBody,
recipients: recipientsArray,
message,
},
})
)
@ -120,39 +114,26 @@ export default function SendSMS() {
return (
<>
<Flex justifyContent='flex-end' marginBottom={20}>
<Button bg={'blue.400'} color={'white'} onClick={onOpen}>
Send SMS
</Button>
</Flex>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Send SMS</ModalHeader>
<ModalCloseButton />
<ModalBody>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={{ base: 5, lg: 8 }}>
<Box backdropBlur='2xl' borderWidth='0px' borderRadius='lg'>
<SendSMSForm
deviceList={deviceList}
formData={formData}
handleChange={handleChange}
/>
</ModalBody>
<ModalFooter>
<Button variant='ghost' mr={3} onClick={onClose}>
Close
</Button>
<Button
variant='outline'
colorScheme='blue'
onClick={handSend}
disabled={sendingSMS}
marginTop={3}
>
{sendingSMS ? <Spinner size='md' /> : 'Send'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
<Box backdropBlur='2xl' borderWidth='0px' borderRadius='lg'></Box>
</SimpleGrid>
</>
)
}

38
web/components/dashboard/UserStats.tsx

@ -1,4 +1,4 @@
import { Box, SimpleGrid, chakra } from '@chakra-ui/react'
import { Box, Grid, GridItem, SimpleGrid, chakra } from '@chakra-ui/react'
import React, { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { selectAuthUser } from '../../store/authSlice'
@ -13,8 +13,12 @@ import { useAppDispatch, useAppSelector } from '../../store/hooks'
const UserStats = () => {
const authUser = useSelector(selectAuthUser)
const { totalApiKeyCount, totalDeviceCount, totalSMSCount } =
useAppSelector(selectStatsData)
const {
totalApiKeyCount,
totalDeviceCount,
totalReceivedSMSCount,
totalSentSMSCount,
} = useAppSelector(selectStatsData)
const statsLoading = useAppSelector(selectStatsLoading)
const dispatch = useAppDispatch()
@ -25,17 +29,26 @@ const UserStats = () => {
return (
<>
<Box maxW='7xl' mx={'auto'} pt={5} px={{ base: 2, sm: 12, md: 17 }}>
<SimpleGrid columns={{ base: 1, md: 2 }}>
<Box maxW='12xl' mx={'auto'} pt={5} px={{ base: 2, sm: 12, md: 17 }}>
<Grid
templateColumns={{ base: 'repeat(1, 1fr)', md: 'repeat(3, 1fr)' }}
gap={6}
>
<GridItem colSpan={1}>
<chakra.h1
textAlign={'center'}
fontSize={'4xl'}
fontSize={'2xl'}
py={10}
fontWeight={'bold'}
>
Welcome {authUser?.name}
</chakra.h1>
<SimpleGrid columns={{ base: 3 }} spacing={{ base: 5, lg: 8 }}>
</GridItem>
<GridItem colSpan={2}>
<SimpleGrid
columns={{ base: 2, md: 4 }}
spacing={{ base: 5, lg: 8 }}
>
<UserStatsCard
title={'Registered '}
stat={`${statsLoading ? '-:-' : totalDeviceCount} Devices`}
@ -46,10 +59,17 @@ const UserStats = () => {
/>
<UserStatsCard
title={'Sent'}
stat={`${statsLoading ? '-:-' : totalSMSCount} SMS Sent`}
stat={`${statsLoading ? '-:-' : totalSentSMSCount} SMS Sent`}
/>
<UserStatsCard
title={'Received'}
stat={`${
statsLoading ? '-:-' : totalReceivedSMSCount
} SMS Received`}
/>
</SimpleGrid>
</SimpleGrid>
</GridItem>
</Grid>
</Box>
</>
)

9
web/components/dashboard/UserStatsCard.tsx

@ -10,20 +10,21 @@ export default function UserStatsCard({ ...props }) {
const { title, stat } = props
return (
<Stat
px={{ base: 4, md: 8 }}
py={'5'}
px={{ base: 2, md: 4 }}
py={'3'}
shadow={'xl'}
border={'1px solid'}
borderColor={useColorModeValue('gray.800', 'gray.500')}
rounded={'lg'}
style={{
height: '90px'
height: '90px',
}}
alignContent={'center'}
>
<StatLabel fontWeight={'medium'} isTruncated>
{title}
</StatLabel>
<StatNumber fontSize={'md'} fontWeight={'bold'}>
<StatNumber fontSize={'md'} fontWeight={'medium'}>
{stat}
</StatNumber>
</Stat>

10
web/components/landing/CodeSnippetSection.tsx

@ -10,9 +10,13 @@ export default function CodeSnippetSection() {
const API_KEY = 'YOUR_API_KEY'
const DEVICE_ID = 'YOUR_DEVICE_ID'
await axios.post(\`\$\{BASE_URL\}/gateway/devices/\$\{DEVICE_ID}/sendSMS?apiKey=\$\{API_KEY\}\`, {
receivers: [ '+251912345678' ],
smsBody: 'Hello World!',
await axios.post(\`\$\{BASE_URL\}/gateway/devices/\$\{DEVICE_ID}/sendSMS\`, {
recipients: [ '+251912345678' ],
message: 'Hello World!',
}, {
headers: {
'x-api-key': API_KEY,
},
})
`

2
web/components/landing/DownloadAppSection.tsx

@ -60,7 +60,7 @@ export default function DownloadAppSection() {
Unlock the power of messaging with our open-source Android SMS
Gateway.
</chakra.p>
<a href='/android' target='_blank'>
<a href='https://dl.textbee.dev' target='_blank'>
<Button
/* flex={1} */
px={4}

2
web/components/landing/howItWorksContent.ts

@ -1,6 +1,6 @@
export const howItWorksContent = [
{
title: 'Step 1: Download The Android App from textbee.dev/android',
title: 'Step 1: Download The Android App from dl.textbee.dev',
description:
'',
},

16
web/components/meta/Meta.tsx

@ -5,9 +5,19 @@ export default function Meta() {
<Head>
<title>TextBee - SMS Gateway</title>
<meta name='viewport' content='initial-scale=1.0, width=device-width' />
<meta name='description' content='Android SMS Gateway' />
<meta name='keywords' content='android, text, sms, gateway, sms-gateway' />
<meta name='author' content='Israel Abebe' />
<meta
name='description'
content={`TextBee is an open-source SMS gateway platform built for Android devices.
It allows businesses to send SMS messages from dashboard or API, receiving SMS messages and forwarding them to a webhook,
streamlining communication and automating SMS workflows. With its robust features,
TextBee is an ideal solution for CRM's, notifications, alerts, two-factor authentication, and various other use cases.
`}
/>
<meta
name='keywords'
content='android, text, sms, gateway, sms-gateway, open-source foss'
/>
<meta name='author' content='Israel Abebe Kokiso' />
<link rel='icon' href='/favicon.ico' />
</Head>
)

2
web/next.config.js

@ -10,7 +10,7 @@ const nextConfig = {
return [
{
source: '/android',
destination: 'https://appdistribution.firebase.dev/i/1439f7af2d1e8e8e',
destination: 'https://dl.textbee.dev',
permanent: false,
},
]

60
web/pages/dashboard.tsx

@ -1,15 +1,21 @@
import { Box, SimpleGrid, useToast } from '@chakra-ui/react'
import ApiKeyList from '../components/dashboard/ApiKeyList'
import {
Box,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
useToast,
} from '@chakra-ui/react'
import UserStats from '../components/dashboard/UserStats'
import GenerateApiKey from '../components/dashboard/GenerateApiKey'
import DeviceList from '../components/dashboard/DeviceList'
import { useSelector } from 'react-redux'
import { selectAuthUser } from '../store/authSlice'
import Router from 'next/router'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import SendSMS from '../components/dashboard/SendSMS'
import ErrorBoundary from '../components/ErrorBoundary'
import dynamic from 'next/dynamic'
import ReceiveSMS from '../components/dashboard/ReceiveSMS'
import APIKeyAndDevices from '../components/dashboard/APIKeyAndDevices'
export default function Dashboard() {
const NoSSRAnimatedWrapper = dynamic(
@ -31,25 +37,39 @@ export default function Dashboard() {
Router.push('/login')
}
}, [authUser, toast])
return (
<>
<NoSSRAnimatedWrapper>
<UserStats />
<DashboardTabView />
</NoSSRAnimatedWrapper>
</>
)
}
const DashboardTabView = () => {
const [tabIndex, setTabIndex] = useState(0)
return (
<Box maxW='7xl' mx={'auto'} pt={5} px={{ base: 2, sm: 12, md: 17 }}>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={{ base: 5, lg: 8 }}>
<Box backdropBlur='2xl' borderWidth='0px' borderRadius='lg'>
<GenerateApiKey />
<ErrorBoundary>
<ApiKeyList />
</ErrorBoundary>
</Box>
<Box backdropBlur='2xl' borderWidth='0px' borderRadius='lg'>
<Tabs isLazy={false} index={tabIndex} onChange={setTabIndex}>
<TabList>
<Tab>API Key and Devices</Tab>
<Tab>Send SMS</Tab>
<Tab>Receive SMS</Tab>
</TabList>
<TabPanels>
<TabPanel>
<APIKeyAndDevices />
</TabPanel>
<TabPanel>
<SendSMS />
<ErrorBoundary>
<DeviceList />
</ErrorBoundary>
</TabPanel>
<TabPanel>
<ReceiveSMS />
</TabPanel>
</TabPanels>
</Tabs>
</Box>
</SimpleGrid>
</Box>
</NoSSRAnimatedWrapper>
)
}

5
web/services/authService.ts

@ -45,6 +45,11 @@ class AuthService {
})
return res.data.data
}
async whoAmI() {
const res = await httpClient.get(`/auth/who-am-i`)
return res.data.data
}
}
export const authService = new AuthService()

5
web/services/gatewayService.ts

@ -34,6 +34,11 @@ class GatewayService {
)
return res.data.data
}
async getReceivedSMSList(deviceId: string) {
const res = await httpClient.get(`/gateway/devices/${deviceId}/getReceivedSMS`)
return res.data.data
}
}
export const gatewayService = new GatewayService()

4
web/services/types.ts

@ -50,8 +50,8 @@ export interface CurrentUserResponse extends BaseResponse {
}
export interface SendSMSRequestPayload {
receivers: string[]
smsBody: string
recipients: string[]
message: string
}
export interface ApiKeyEntity {

35
web/store/deviceSlice.ts

@ -11,6 +11,10 @@ const initialState = {
item: null,
list: [],
sendingSMS: false,
receivedSMSList: {
loading: false,
data: [],
},
}
export const fetchDevices = createAsyncThunk(
@ -29,6 +33,22 @@ export const fetchDevices = createAsyncThunk(
}
)
export const fetchReceivedSMSList = createAsyncThunk(
'device/fetchReceivedSMSList',
async (id: string, { rejectWithValue }) => {
try {
const res = await gatewayService.getReceivedSMSList(id)
return res
} catch (e) {
toast({
title: e.response.data.error || 'Failed to Fetch received sms list',
status: 'error',
})
return rejectWithValue(e.response.data)
}
}
)
export const deleteDevice = createAsyncThunk(
'device/deleteDevice',
async (id: string, { rejectWithValue, dispatch }) => {
@ -100,6 +120,19 @@ export const deviceSlice = createSlice({
.addCase(sendSMS.rejected, (state) => {
state.sendingSMS = false
})
.addCase(fetchReceivedSMSList.pending, (state) => {
state.receivedSMSList.loading = true
})
.addCase(
fetchReceivedSMSList.fulfilled,
(state, action: PayloadAction<any>) => {
state.receivedSMSList.loading = false
state.receivedSMSList.data = action.payload
}
)
.addCase(fetchReceivedSMSList.rejected, (state) => {
state.receivedSMSList.loading = false
})
},
})
@ -109,5 +142,7 @@ export const selectDeviceList = (state: RootState) => state.device.list
export const selectDeviceItem = (state: RootState) => state.device.item
export const selectDeviceLoading = (state: RootState) => state.device.loading
export const selectSendingSMS = (state: RootState) => state.device.sendingSMS
export const selectReceivedSMSList = (state: RootState) =>
state.device.receivedSMSList
export default deviceSlice.reducer

3
web/store/statsSlice.ts

@ -11,7 +11,8 @@ const initialState = {
data: {
totalApiKeyCount: 0,
totalDeviceCount: 0,
totalSMSCount: 0,
totalReceivedSMSCount: 0,
totalSentSMSCount: 0,
},
}

Loading…
Cancel
Save