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. 17
      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. 254
      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. 27
      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. 101
      web/components/dashboard/SendSMS.tsx
  45. 76
      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. 68
      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 ## Usage
1. Go to [textbee.dev](https://textbee.dev) and register or login with your account 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 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 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 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 API_KEY = 'YOUR_API_KEY';
const DEVICE_ID = 'YOUR_DEVICE_ID'; 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,
},
});
``` ```

17
android/app/build.gradle

@ -10,10 +10,16 @@ android {
applicationId "com.vernu.sms" applicationId "com.vernu.sms"
minSdk 24 minSdk 24
targetSdk 32 targetSdk 32
versionCode 9
versionName "2.2.0"
versionCode 10
versionName "2.3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// javaCompileOptions {
// annotationProcessorOptions {
// arguments["room.schemaLocation"] = "$projectDir/schemas"
// }
// }
} }
buildTypes { buildTypes {
@ -46,4 +52,9 @@ dependencies {
implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.journeyapps:zxing-android-embedded:4.1.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" xmlns:tools="http://schemas.android.com/tools"
package="com.vernu.sms"> 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.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.SEND_SMS" /> <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 <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@ -21,8 +30,31 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" /> <action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter> </intent-filter>
</service> </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 <activity
android:name="com.vernu.sms.activities.MainActivity"
android:name=".activities.MainActivity"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <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);
}
}

254
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.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import android.Manifest;
import android.content.ClipData; import android.content.ClipData;
import android.content.ClipboardManager; import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.telephony.SubscriptionInfo;
import android.telephony.SubscriptionManager;
import android.util.Log; import android.util.Log;
import android.view.View; import android.view.View;
import android.widget.Button; import android.widget.Button;
@ -25,95 +19,57 @@ import android.widget.RadioGroup;
import android.widget.Switch; import android.widget.Switch;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessaging;
import com.google.zxing.integration.android.IntentIntegrator; import com.google.zxing.integration.android.IntentIntegrator;
import com.google.zxing.integration.android.IntentResult; 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.R;
import com.vernu.sms.dtos.RegisterDeviceInputDTO; import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO; import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper; 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.Call;
import retrofit2.Callback; import retrofit2.Callback;
import retrofit2.Response; import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
private Context mContext; private Context mContext;
private Retrofit retrofit;
private GatewayApiService gatewayApiService;
private Switch gatewaySwitch;
private Switch gatewaySwitch, receiveSMSSwitch;
private EditText apiKeyEditText, fcmTokenEditText; private EditText apiKeyEditText, fcmTokenEditText;
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn; private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn;
private ImageButton copyDeviceIdImgBtn; private ImageButton copyDeviceIdImgBtn;
private TextView deviceBrandAndModelTxt, deviceIdTxt; private TextView deviceBrandAndModelTxt, deviceIdTxt;
private RadioGroup defaultSimSlotRadioGroup; 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 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 String deviceId = null;
private static final String TAG = "MainActivity";
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
mContext = getApplicationContext(); 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); setContentView(R.layout.activity_main);
gatewaySwitch = findViewById(R.id.gatewaySwitch); gatewaySwitch = findViewById(R.id.gatewaySwitch);
receiveSMSSwitch = findViewById(R.id.receiveSMSSwitch);
apiKeyEditText = findViewById(R.id.apiKeyEditText); apiKeyEditText = findViewById(R.id.apiKeyEditText);
fcmTokenEditText = findViewById(R.id.fcmTokenEditText); fcmTokenEditText = findViewById(R.id.fcmTokenEditText);
registerDeviceBtn = findViewById(R.id.registerDeviceBtn); registerDeviceBtn = findViewById(R.id.registerDeviceBtn);
grantSMSPermissionBtn = findViewById(R.id.grantSMSPermissionBtn); grantSMSPermissionBtn = findViewById(R.id.grantSMSPermissionBtn);
scanQRBtn = findViewById(R.id.scanQRButton); scanQRBtn = findViewById(R.id.scanQRButton);
deviceBrandAndModelTxt = findViewById(R.id.deviceBrandAndModelTxt); deviceBrandAndModelTxt = findViewById(R.id.deviceBrandAndModelTxt);
deviceIdTxt = findViewById(R.id.deviceIdTxt); deviceIdTxt = findViewById(R.id.deviceIdTxt);
copyDeviceIdImgBtn = findViewById(R.id.copyDeviceIdImgBtn); copyDeviceIdImgBtn = findViewById(R.id.copyDeviceIdImgBtn);
defaultSimSlotRadioGroup = findViewById(R.id.defaultSimSlotRadioGroup); 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); deviceIdTxt.setText(deviceId);
deviceBrandAndModelTxt.setText(Build.BRAND + " " + Build.MODEL); deviceBrandAndModelTxt.setText(Build.BRAND + " " + Build.MODEL);
@ -123,14 +79,19 @@ public class MainActivity extends AppCompatActivity {
registerDeviceBtn.setText("Update"); 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.setEnabled(false);
grantSMSPermissionBtn.setText("SMS Permission Granted");
grantSMSPermissionBtn.setText("Permission Granted");
renderAvailableSimOptions();
} else { } else {
Snackbar.make(grantSMSPermissionBtn, "Please Grant Required Permissions to continue: " + Arrays.toString(missingPermissions), Snackbar.LENGTH_SHORT).show();
grantSMSPermissionBtn.setEnabled(true); grantSMSPermissionBtn.setEnabled(true);
grantSMSPermissionBtn.setOnClickListener(view -> handleSMSRequestPermission(view));
grantSMSPermissionBtn.setOnClickListener(this::handleRequestPermissions);
} }
// TextBeeUtils.startStickyNotificationService(mContext);
copyDeviceIdImgBtn.setOnClickListener(view -> { copyDeviceIdImgBtn.setOnClickListener(view -> {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText("Device ID", deviceId); ClipData clip = ClipData.newPlainText("Device ID", deviceId);
@ -138,70 +99,120 @@ public class MainActivity extends AppCompatActivity {
Snackbar.make(view, "Copied", Snackbar.LENGTH_LONG).show(); 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) -> { gatewaySwitch.setOnCheckedChangeListener((compoundButton, isCheked) -> {
View view = compoundButton.getRootView(); View view = compoundButton.getRootView();
compoundButton.setEnabled(false); compoundButton.setEnabled(false);
String key = apiKeyEditText.getText().toString(); String key = apiKeyEditText.getText().toString();
RegisterDeviceInputDTO registerDeviceInput = new RegisterDeviceInputDTO(); RegisterDeviceInputDTO registerDeviceInput = new RegisterDeviceInputDTO();
registerDeviceInput.setEnabled(isCheked); 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>() { apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override @Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) { 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(); 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); compoundButton.setEnabled(true);
} }
@Override @Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) { public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Snackbar.make(view, "An error occured :(", Snackbar.LENGTH_LONG).show(); Snackbar.make(view, "An error occured :(", Snackbar.LENGTH_LONG).show();
compoundButton.setEnabled(true); 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()); registerDeviceBtn.setOnClickListener(view -> handleRegisterDevice());
scanQRBtn.setOnClickListener(view -> { scanQRBtn.setOnClickListener(view -> {
IntentIntegrator intentIntegrator = new IntentIntegrator(MainActivity.this); IntentIntegrator intentIntegrator = new IntentIntegrator(MainActivity.this);
intentIntegrator.setPrompt("Go to textbee.dev/dashboard and click Register Device to generate QR Code"); intentIntegrator.setPrompt("Go to textbee.dev/dashboard and click Register Device to generate QR Code");
intentIntegrator.setRequestCode(SCAN_QR_REQUEST_CODE); intentIntegrator.setRequestCode(SCAN_QR_REQUEST_CODE);
intentIntegrator.initiateScan(); 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 @Override
public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) { public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, 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();
return;
}
}
}
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() { private void handleRegisterDevice() {
@ -230,86 +241,63 @@ public class MainActivity extends AppCompatActivity {
registerDeviceInput.setModel(Build.MODEL); registerDeviceInput.setModel(Build.MODEL);
registerDeviceInput.setBuildId(Build.ID); registerDeviceInput.setBuildId(Build.ID);
registerDeviceInput.setOs(Build.VERSION.BASE_OS); 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>() { apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override @Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) { public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
if (response.isSuccessful()) {
SharedPreferenceHelper.setSharedPreferenceString(mContext, "API_KEY", newKey);
Log.e("API_RESP", response.toString());
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 {
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show(); 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, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
registerDeviceBtn.setEnabled(true); registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update"); registerDeviceBtn.setText("Update");
}
}
@Override @Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) { public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Snackbar.make(view, "An error occured :(", Snackbar.LENGTH_LONG).show(); Snackbar.make(view, "An error occured :(", Snackbar.LENGTH_LONG).show();
registerDeviceBtn.setEnabled(true); registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update"); 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(); 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 @Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data); super.onActivityResult(requestCode, resultCode, data);
if (requestCode == SCAN_QR_REQUEST_CODE) { if (requestCode == SCAN_QR_REQUEST_CODE) {
IntentResult intentResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data); IntentResult intentResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);
if (intentResult != null) {
if (intentResult.getContents() == null) {
Toast.makeText(getBaseContext(), "Canceled", Toast.LENGTH_SHORT).show();
} else {
String scannedQR = intentResult.getContents();
apiKeyEditText.setText(scannedQR);
handleRegisterDevice();
}
if (intentResult == null || intentResult.getContents() == null) {
Toast.makeText(getBaseContext(), "Canceled", Toast.LENGTH_SHORT).show();
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 os;
private String osVersion; private String osVersion;
private String appVersionName; private String appVersionName;
private String appVersionCode;
private int appVersionCode;
public RegisterDeviceInputDTO() { public RegisterDeviceInputDTO() {
} }
@ -100,11 +100,11 @@ public class RegisterDeviceInputDTO {
this.appVersionName = appVersionName; this.appVersionName = appVersionName;
} }
public String getAppVersionCode() {
public int getAppVersionCode() {
return appVersionCode; return appVersionCode;
} }
public void setAppVersionCode(String appVersionCode) {
public void setAppVersionCode(int appVersionCode) {
this.appVersionCode = 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); SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
return settings.getBoolean(key, defValue); 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; package com.vernu.sms.models;
public class SMSPayload { public class SMSPayload {
private String[] recipients;
private String message;
// Legacy fields that are no longer used
private String[] receivers; private String[] receivers;
private String smsBody; 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.net.Uri;
import android.os.Build; import android.os.Build;
import android.util.Log; import android.util.Log;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import com.google.firebase.messaging.FirebaseMessagingService; import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage; import com.google.firebase.messaging.RemoteMessage;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.vernu.sms.AppConstants;
import com.vernu.sms.R; import com.vernu.sms.R;
import com.vernu.sms.activities.MainActivity; import com.vernu.sms.activities.MainActivity;
import com.vernu.sms.helpers.SMSHelper; import com.vernu.sms.helpers.SMSHelper;
import com.vernu.sms.helpers.SharedPreferenceHelper; import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.models.SMSPayload; import com.vernu.sms.models.SMSPayload;
public class FCMService extends FirebaseMessagingService { 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"; private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "N1";
@Override @Override
public void onMessageReceived(RemoteMessage remoteMessage) { public void onMessageReceived(RemoteMessage remoteMessage) {
Log.d("FCM_MESSAGE", remoteMessage.getData().toString());
Log.d(TAG, remoteMessage.getData().toString());
Gson gson = new Gson(); Gson gson = new Gson();
SMSPayload smsPayload = gson.fromJson(remoteMessage.getData().get("smsData"), SMSPayload.class); SMSPayload smsPayload = gson.fromJson(remoteMessage.getData().get("smsData"), SMSPayload.class);
// Check if message contains a data payload. // Check if message contains a data payload.
if (remoteMessage.getData().size() > 0) { 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) { if(preferedSim == -1) {
SMSHelper.sendSMS(receiver, smsPayload.getSmsBody());
SMSHelper.sendSMS(receiver, smsPayload.getMessage());
continue; continue;
} }
try { try {
SMSHelper.sendSMSFromSpecificSim(receiver, smsPayload.getSmsBody(), preferedSim);
SMSHelper.sendSMSFromSpecificSim(receiver, smsPayload.getMessage(), preferedSim);
} catch(Exception e) { } catch(Exception e) {
Log.d("SMS_SEND_ERROR", e.getMessage()); 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; 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.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO; import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.http.Body; import retrofit2.http.Body;
import retrofit2.http.Header;
import retrofit2.http.PATCH; import retrofit2.http.PATCH;
import retrofit2.http.POST; import retrofit2.http.POST;
import retrofit2.http.Path; import retrofit2.http.Path;
import retrofit2.http.Query;
public interface GatewayApiService { public interface GatewayApiService {
@POST("gateway/devices") @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}") @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"?> <?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:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".activities.MainActivity"> 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 <ScrollView
android:id="@+id/scrollView2" android:id="@+id/scrollView2"
android:layout_width="match_parent" 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 <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> 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 <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="#ccccccee" android:background="#ccccccee"
android:layout_margin="5dp"
android:orientation="horizontal"> android:orientation="horizontal">
<LinearLayout <LinearLayout
@ -91,7 +46,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ems="10" android:ems="10"
android:hint="key"
android:hint="API Key"
android:inputType="text" android:inputType="text"
android:minHeight="48dp" android:minHeight="48dp"
android:textIsSelectable="true" /> android:textIsSelectable="true" />
@ -137,6 +92,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:layout_margin="5dp"
android:orientation="horizontal"> android:orientation="horizontal">
<LinearLayout <LinearLayout
@ -229,9 +185,119 @@
</LinearLayout> </LinearLayout>
</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> </LinearLayout>
</ScrollView> </ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

1
api/.env.example

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

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

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

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

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

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

@ -14,6 +14,7 @@ import {
} from './schemas/password-reset.schema' } from './schemas/password-reset.schema'
import { MailService } from 'src/mail/mail.service' import { MailService } from 'src/mail/mail.service'
import { RequestResetPasswordInputDTO, ResetPasswordInputDTO } from './auth.dto' import { RequestResetPasswordInputDTO, ResetPasswordInputDTO } from './auth.dto'
import { AccessLog } from './schemas/access-log.schema'
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
@ -22,6 +23,7 @@ export class AuthService {
@InjectModel(ApiKey.name) private apiKeyModel: Model<ApiKeyDocument>, @InjectModel(ApiKey.name) private apiKeyModel: Model<ApiKeyDocument>,
@InjectModel(PasswordReset.name) @InjectModel(PasswordReset.name)
private passwordResetModel: Model<PasswordResetDocument>, private passwordResetModel: Model<PasswordResetDocument>,
@InjectModel(AccessLog.name) private accessLogModel: Model<AccessLog>,
private readonly mailService: MailService, 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)
})
} }
} }

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

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

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

@ -8,10 +8,23 @@ import {
Request, Request,
Get, Get,
Delete, Delete,
HttpCode,
HttpStatus,
} from '@nestjs/common' } 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 { 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 { GatewayService } from './gateway.service'
import { CanModifyDevice } from './guards/can-modify-device.guard' import { CanModifyDevice } from './guards/can-modify-device.guard'
@ -30,11 +43,6 @@ export class GatewayController {
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@ApiOperation({ summary: 'Register device' }) @ApiOperation({ summary: 'Register device' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@Post('/devices') @Post('/devices')
async registerDevice(@Body() input: RegisterDeviceInputDTO, @Request() req) { async registerDevice(@Body() input: RegisterDeviceInputDTO, @Request() req) {
const data = await this.gatewayService.registerDevice(input, req.user) const data = await this.gatewayService.registerDevice(input, req.user)
@ -43,11 +51,6 @@ export class GatewayController {
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@ApiOperation({ summary: 'List of registered devices' }) @ApiOperation({ summary: 'List of registered devices' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@Get('/devices') @Get('/devices')
async getDevices(@Request() req) { async getDevices(@Request() req) {
const data = await this.gatewayService.getDevicesForUser(req.user) const data = await this.gatewayService.getDevicesForUser(req.user)
@ -55,11 +58,6 @@ export class GatewayController {
} }
@ApiOperation({ summary: 'Update device' }) @ApiOperation({ summary: 'Update device' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@UseGuards(AuthGuard, CanModifyDevice) @UseGuards(AuthGuard, CanModifyDevice)
@Patch('/devices/:id') @Patch('/devices/:id')
async updateDevice( async updateDevice(
@ -71,11 +69,6 @@ export class GatewayController {
} }
@ApiOperation({ summary: 'Delete device' }) @ApiOperation({ summary: 'Delete device' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@UseGuards(AuthGuard, CanModifyDevice) @UseGuards(AuthGuard, CanModifyDevice)
@Delete('/devices/:id') @Delete('/devices/:id')
async deleteDevice(@Param('id') deviceId: string) { async deleteDevice(@Param('id') deviceId: string) {
@ -84,11 +77,6 @@ export class GatewayController {
} }
@ApiOperation({ summary: 'Send SMS to a device' }) @ApiOperation({ summary: 'Send SMS to a device' })
@ApiQuery({
name: 'apiKey',
required: false,
description: 'Required if jwt bearer token not provided',
})
@UseGuards(AuthGuard, CanModifyDevice) @UseGuards(AuthGuard, CanModifyDevice)
@Post('/devices/:id/sendSMS') @Post('/devices/:id/sendSMS')
async sendSMS( async sendSMS(
@ -98,4 +86,24 @@ export class GatewayController {
const data = await this.gatewayService.sendSMS(deviceId, smsData) const data = await this.gatewayService.sendSMS(deviceId, smsData)
return { data } 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({ @ApiProperty({
type: String, type: String,
required: true, required: true,
description: 'SMS text',
description: 'The message to send',
}) })
smsBody: string
message: string
@ApiProperty({ @ApiProperty({
type: Array, type: Array,
required: true, required: true,
description: 'Array of phone numbers',
description: 'List of phone numbers to send the SMS to',
example: ['+2519xxxxxxxx', '+2517xxxxxxxx'], 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[] receivers: string[]
} }
export class SendSMSInputDTO extends SMSData {} 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 { GatewayService } from './gateway.service'
import { AuthModule } from '../auth/auth.module' import { AuthModule } from '../auth/auth.module'
import { UsersModule } from '../users/users.module' import { UsersModule } from '../users/users.module'
import { SMS, SMSSchema } from './schemas/sms.schema'
@Module({ @Module({
imports: [ imports: [
@ -13,6 +14,10 @@ import { UsersModule } from '../users/users.module'
name: Device.name, name: Device.name,
schema: DeviceSchema, schema: DeviceSchema,
}, },
{
name: SMS.name,
schema: SMSSchema,
},
]), ]),
AuthModule, AuthModule,
UsersModule, 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 { Device, DeviceDocument } from './schemas/device.schema'
import { Model } from 'mongoose' import { Model } from 'mongoose'
import * as firebaseAdmin from 'firebase-admin' 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 { User } from '../users/schemas/user.schema'
import { AuthService } from 'src/auth/auth.service' import { AuthService } from 'src/auth/auth.service'
import { SMS } from './schemas/sms.schema'
import { SMSType } from './sms-type.enum'
@Injectable() @Injectable()
export class GatewayService { export class GatewayService {
constructor( constructor(
@InjectModel(Device.name) private deviceModel: Model<DeviceDocument>, @InjectModel(Device.name) private deviceModel: Model<DeviceDocument>,
@InjectModel(SMS.name) private smsModel: Model<SMS>,
private authService: AuthService, 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> { 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) const device = await this.deviceModel.findById(deviceId)
if (!device?.enabled) { if (!device?.enabled) {
@ -88,11 +105,15 @@ export class GatewayService {
) )
} }
const stringifiedSMSData = JSON.stringify(updatedSMSData)
const payload: any = { const payload: any = {
data: { data: {
smsData: JSON.stringify(smsData),
smsData: stringifiedSMSData,
}, },
} }
// TODO: Save SMS and Implement a queue to send the SMS if recipients are too many
try { try {
const response = await firebaseAdmin const response = await firebaseAdmin
.messaging() .messaging()
@ -100,7 +121,7 @@ export class GatewayService {
this.deviceModel this.deviceModel
.findByIdAndUpdate(deviceId, { .findByIdAndUpdate(deviceId, {
$inc: { sentSMSCount: smsData.receivers.length },
$inc: { sentSMSCount: updatedSMSData.recipients.length },
}) })
.exec() .exec()
.catch((e) => { .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) { async getStatsForUser(user: User) {
const devices = await this.deviceModel.find({ user: user._id }) const devices = await this.deviceModel.find({ user: user._id })
const apiKeys = await this.authService.getUserApiKeys(user) const apiKeys = await this.authService.getUserApiKeys(user)
const totalSMSCount = devices.reduce((acc, device) => {
const totalSentSMSCount = devices.reduce((acc, device) => {
return acc + (device.sentSMSCount || 0) return acc + (device.sentSMSCount || 0)
}, 0) }, 0)
const totalReceivedSMSCount = devices.reduce((acc, device) => {
return acc + (device.receivedSMSCount || 0)
}, 0)
const totalDeviceCount = devices.length const totalDeviceCount = devices.length
const totalApiKeyCount = apiKeys.length const totalApiKeyCount = apiKeys.length
return { return {
totalSMSCount,
totalSentSMSCount,
totalReceivedSMSCount,
totalDeviceCount, totalDeviceCount,
totalApiKeyCount, totalApiKeyCount,
} }

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

@ -46,6 +46,9 @@ export class Device {
@Prop({ type: Number, default: 0 }) @Prop({ type: Number, default: 0 })
sentSMSCount: number sentSMSCount: number
@Prop({ type: Number, default: 0 })
receivedSMSCount: number
} }
export const DeviceSchema = SchemaFactory.createForClass(Device) 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 { export class SMS {
_id?: Types.ObjectId _id?: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: Device.name })
@Prop({ type: Types.ObjectId, ref: Device.name, required: true })
device: Device device: Device
@Prop({ type: String, required: true })
@Prop({ type: String })
message: string message: string
@Prop({ type: Boolean, default: false })
encrypted: boolean
@Prop({ type: String })
encryptedMessage: string
@Prop({ type: String, required: true }) @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) 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') .setDescription('TextBee - Android SMS Gateway API Docs')
.setVersion('1.0') .setVersion('1.0')
.addBearerAuth() .addBearerAuth()
.addApiKey({
type: 'apiKey',
name: 'x-api-key',
in: 'header',
})
.build() .build()
const document = SwaggerModule.createDocument(app, config) const document = SwaggerModule.createDocument(app, config)
SwaggerModule.setup('', app, document, { SwaggerModule.setup('', app, document, {

2
web/components/Footer.tsx

@ -25,7 +25,7 @@ export default function Footer() {
<Stack direction={'row'} spacing={6}> <Stack direction={'row'} spacing={6}>
<Link href='/'>Home</Link> <Link href='/'>Home</Link>
<Link href='/dashboard'>Dashboard</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> <Link href='https://github.com/vernu/textbee'>Github</Link>
</Stack> </Stack>
</Container> </Container>

18
web/components/Navbar.tsx

@ -19,12 +19,30 @@ import Router from 'next/router'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { logout, selectAuthUser } from '../store/authSlice' import { logout, selectAuthUser } from '../store/authSlice'
import Image from 'next/image' import Image from 'next/image'
import { useEffect } from 'react'
import { authService } from '../services/authService'
export default function Navbar() { export default function Navbar() {
const dispatch = useDispatch() const dispatch = useDispatch()
const { colorMode, toggleColorMode } = useColorMode() const { colorMode, toggleColorMode } = useColorMode()
const authUser = useSelector(selectAuthUser) 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 ( return (
<> <>
<Box <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 ( return (
<TableContainer> <TableContainer>
<Table variant='simple'>
<Table variant='striped'>
<Thead> <Thead>
<Tr> <Tr>
<Th>Your API Keys</Th> <Th>Your API Keys</Th>

2
web/components/dashboard/DeviceList.tsx

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

101
web/components/dashboard/SendSMS.tsx

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

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

9
web/components/dashboard/UserStatsCard.tsx

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

10
web/components/landing/CodeSnippetSection.tsx

@ -10,9 +10,13 @@ export default function CodeSnippetSection() {
const API_KEY = 'YOUR_API_KEY' const API_KEY = 'YOUR_API_KEY'
const DEVICE_ID = 'YOUR_DEVICE_ID' 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 Unlock the power of messaging with our open-source Android SMS
Gateway. Gateway.
</chakra.p> </chakra.p>
<a href='/android' target='_blank'>
<a href='https://dl.textbee.dev' target='_blank'>
<Button <Button
/* flex={1} */ /* flex={1} */
px={4} px={4}

2
web/components/landing/howItWorksContent.ts

@ -1,6 +1,6 @@
export const howItWorksContent = [ 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: description:
'', '',
}, },

16
web/components/meta/Meta.tsx

@ -5,9 +5,19 @@ export default function Meta() {
<Head> <Head>
<title>TextBee - SMS Gateway</title> <title>TextBee - SMS Gateway</title>
<meta name='viewport' content='initial-scale=1.0, width=device-width' /> <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' /> <link rel='icon' href='/favicon.ico' />
</Head> </Head>
) )

2
web/next.config.js

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

68
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 UserStats from '../components/dashboard/UserStats'
import GenerateApiKey from '../components/dashboard/GenerateApiKey'
import DeviceList from '../components/dashboard/DeviceList'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { selectAuthUser } from '../store/authSlice' import { selectAuthUser } from '../store/authSlice'
import Router from 'next/router' import Router from 'next/router'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import SendSMS from '../components/dashboard/SendSMS' import SendSMS from '../components/dashboard/SendSMS'
import ErrorBoundary from '../components/ErrorBoundary'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import ReceiveSMS from '../components/dashboard/ReceiveSMS'
import APIKeyAndDevices from '../components/dashboard/APIKeyAndDevices'
export default function Dashboard() { export default function Dashboard() {
const NoSSRAnimatedWrapper = dynamic( const NoSSRAnimatedWrapper = dynamic(
@ -31,25 +37,39 @@ export default function Dashboard() {
Router.push('/login') Router.push('/login')
} }
}, [authUser, toast]) }, [authUser, toast])
return (
<>
<NoSSRAnimatedWrapper>
<UserStats />
<DashboardTabView />
</NoSSRAnimatedWrapper>
</>
)
}
const DashboardTabView = () => {
const [tabIndex, setTabIndex] = useState(0)
return ( return (
<NoSSRAnimatedWrapper>
<UserStats />
<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'>
<Box maxW='7xl' mx={'auto'} pt={5} px={{ base: 2, sm: 12, md: 17 }}>
<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 /> <SendSMS />
<ErrorBoundary>
<DeviceList />
</ErrorBoundary>
</Box>
</SimpleGrid>
</Box>
</NoSSRAnimatedWrapper>
</TabPanel>
<TabPanel>
<ReceiveSMS />
</TabPanel>
</TabPanels>
</Tabs>
</Box>
) )
} }

5
web/services/authService.ts

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

5
web/services/gatewayService.ts

@ -34,6 +34,11 @@ class GatewayService {
) )
return res.data.data 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() export const gatewayService = new GatewayService()

4
web/services/types.ts

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

35
web/store/deviceSlice.ts

@ -11,6 +11,10 @@ const initialState = {
item: null, item: null,
list: [], list: [],
sendingSMS: false, sendingSMS: false,
receivedSMSList: {
loading: false,
data: [],
},
} }
export const fetchDevices = createAsyncThunk( 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( export const deleteDevice = createAsyncThunk(
'device/deleteDevice', 'device/deleteDevice',
async (id: string, { rejectWithValue, dispatch }) => { async (id: string, { rejectWithValue, dispatch }) => {
@ -100,6 +120,19 @@ export const deviceSlice = createSlice({
.addCase(sendSMS.rejected, (state) => { .addCase(sendSMS.rejected, (state) => {
state.sendingSMS = false 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 selectDeviceItem = (state: RootState) => state.device.item
export const selectDeviceLoading = (state: RootState) => state.device.loading export const selectDeviceLoading = (state: RootState) => state.device.loading
export const selectSendingSMS = (state: RootState) => state.device.sendingSMS export const selectSendingSMS = (state: RootState) => state.device.sendingSMS
export const selectReceivedSMSList = (state: RootState) =>
state.device.receivedSMSList
export default deviceSlice.reducer export default deviceSlice.reducer

3
web/store/statsSlice.ts

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

Loading…
Cancel
Save