Compare commits

...
47 Commits
Author SHA1 Message Date
Israel Abebe 0fe0f48ef9 Merge pull request #97 from vernu/improve-status-and-error-tracking
Improve sms status and error tracking logic
2025-06-16 08:21:08 +03:00
Israel Abebe f520f378d0 Merge pull request #96 from vernu/improve-call-reliability
Improve received sms and status tracking reliability using work manager
2025-06-16 08:17:53 +03:00
isra el b0ce11e392 chore(api): improve sms status and error tracking logic 2025-06-16 08:17:16 +03:00
Israel Abebe 7012b04439 Merge pull request #95 from vernu/fix-version-update-disabling-device-issue
fix device status getting disabled issue on new version update
2025-06-16 08:13:07 +03:00
isra el 375cf63adb chore(android): bump android version to 15(2.6.2) 2025-06-16 08:12:53 +03:00
isra el 6886dd2f1a feat(android): improve received sms and status tracking reliability 2025-06-16 08:12:15 +03:00
isra el 5acec3f04a fix(android): ensure device status does not get disabled on new version update 2025-06-14 17:23:57 +03:00
Israel Abebe 6861a4d36b Merge pull request #94 from vernu/refactor-delete-account
refactor delete account component
2025-06-11 11:56:39 +03:00
isra el 54fb586d9b refactor(web): refactor delete account component 2025-06-11 11:50:39 +03:00
Israel Abebe 60bcd691c8 Merge pull request #93 from vernu/fix-status-display-cutoff-date
update message status display cut-off date to Jun 5
2025-06-11 11:47:41 +03:00
isra el dd2c01678f chore(web): update message status cut-off date to Jun 5 2025-06-11 11:44:54 +03:00
Israel Abebe 2633fc80cc Merge pull request #92 from vernu/fix-bulk-sms-limits
raise request body limit from 100kb to 2mb
2025-06-11 11:31:24 +03:00
Israel Abebe da9efc036b Merge pull request #91 from vernu/fix-message-status
only show message status if device is using latest version and message is sent after v2.6 release
2025-06-11 11:30:53 +03:00
isra el 7d2b8093ae chore(api): raise request body limit from 100kb to 2mb 2025-06-11 11:30:31 +03:00
isra el 15a67446ea chore(web): only show message status if device is using latest version and message is sent after v2.6 release 2025-06-11 11:27:55 +03:00
Israel Abebe 50e7b7536d Merge pull request #90 from vernu/hot-fix-jun-8
prevent device deactivation during new version update
2025-06-08 21:56:14 +03:00
isra el d868b43d52 fix(api): fix device deactivation issue during new version update 2025-06-08 21:55:20 +03:00
Israel Abebe c4dc4bcb88 Merge pull request #89 from vernu/improve-dashboard-navigation
prefetch dashboard nav links for better performance
2025-06-05 14:25:53 +03:00
isra el c7b206be91 chore(web): prefetch dashboard nav links for better performance 2025-06-05 14:25:12 +03:00
Israel Abebe f88c94fbbd Update README.md 2025-06-05 08:50:24 +03:00
Israel Abebe 59525cb126 Merge pull request #88 from vernu/login-redirect-hotfix
fix(web): fix redirection issues after login
2025-06-04 22:36:33 +03:00
Israel Abebe 383cf213d8 Merge pull request #87 from vernu/fix-duplicate-received-sms
fix duplicate received sms issue
2025-06-04 22:34:10 +03:00
isra el 9e15483752 fix(web): fix redirection issues after login 2025-06-04 22:32:31 +03:00
isra el ea49398e4d fix(android): fix duplicate received sms issue 2025-06-04 22:30:11 +03:00
Israel Abebe cde19e01e0 Merge pull request #86 from vernu/fix-docker-build2
fix dockerfile
2025-06-04 21:40:24 +03:00
isra el ef59f86071 fix(infra): fix dockerfile 2025-06-04 21:36:14 +03:00
Israel Abebe 1e602b85e1 Merge pull request #85 from vernu/fix-docker-build
fix docker-build issue for web
2025-06-04 21:23:10 +03:00
isra el 2b110b6fc5 fix(infra): fix docker-build for web 2025-06-04 21:18:58 +03:00
Israel Abebe 89ed36849b Merge pull request #84 from vernu/bump-android-app-version
Bump android app version to 2.6.0(13)
2025-06-04 21:10:50 +03:00
Israel Abebe 0ac3ccb144 Merge pull request #83 from vernu/dev
Dev
2025-06-04 21:09:34 +03:00
isra el 9b9d92592c chore(android): bump android app version to 2.6.0(13) 2025-06-04 21:08:24 +03:00
Israel Abebe 7fc7fbf86f Merge pull request #82 from vernu/fix-cases
ensure status is saved in lowercases
2025-06-04 20:57:11 +03:00
isra el cbffeca689 chore(api): ensure status is saved in lowercases 2025-06-04 20:52:59 +03:00
Israel Abebe dfd6c5c3d2 Merge pull request #81 from vernu/web-dashboard-ui
UI/UX redesign of web dashboard
2025-06-04 20:47:12 +03:00
isra el c1af584093 fix(infra): fix failing build action 2025-06-04 20:41:45 +03:00
isra el db3ac97af8 ui(web): rebuild web dashboard ui/ux 2025-06-04 20:37:23 +03:00
Israel Abebe 25ca1f7be0 Merge pull request #80 from vernu/track-sms-status
Track sms status
2025-06-04 20:24:25 +03:00
isra el 2be8b3e8c7 chore(android): sync app and device info and restart sticky notification on restart 2025-06-03 05:20:45 +03:00
isra el 7694f3f60a feat(api): create endpoints for getting a specific sms and sms batch by id 2025-06-03 05:19:47 +03:00
isra el 52e88e7e36 feat(api): track sms status 2025-06-02 20:47:15 +03:00
isra el 64859b38ad feat(android): track sms sent, delivered, and failed status 2025-06-02 20:42:14 +03:00
Israel Abebe 601f33d95e Merge pull request #79 from vernu/android-ui-ux
improve android app ui/ux
2025-06-02 18:17:57 +03:00
isra el f355065bfb ui(android): update logo and brand colors 2025-06-02 13:02:37 +03:00
isra el 0bd1818678 chore(android): make link to dashboard clickable 2025-05-31 14:02:19 +03:00
isra el cdf3b7bb51 feat(android): enable sticky notification service to prevent app from getting killed 2025-05-31 13:59:20 +03:00
isra el 61a666da35 chore(android): track android app version better. 2025-05-31 13:43:36 +03:00
isra el fa81292bd8 ui(android): major ui/ux improvement on the android app and add crashlytics 2025-05-31 13:19:45 +03:00
150 changed files with 5859 additions and 3246 deletions
-1
View File
@@ -62,7 +62,6 @@ jobs:
run: |
cd web
pnpm install
pnpm run prisma:generate
pnpm run build
build-and-test-android:
+4 -4
View File
@@ -1,11 +1,11 @@
# TextBee - Android SMS Gateway
# textbee.dev - android sms gateway
TextBee is an open-source SMS gateway that enables users to send and receive SMS messages via a web dashboard or a REST API. Perfect for businesses, developers, and hobbyists who need a reliable and cost-effective way to automate SMS messaging.
textbee.dev is an open-source SMS gateway that enables users to send and receive SMS messages via a web dashboard or a REST API. Perfect for businesses, developers, and hobbyists who need a reliable and cost-effective way to automate SMS messaging.
- **Technology stack**: React, Next.js, Node.js, NestJs, MongoDB, Android, Java
- **Link**: [https://textbee.dev](https://textbee.dev/)
![](https://ik.imagekit.io/vernu/textbee/texbee-landing-light.png?updatedAt=1687076964687)
![](https://ik.imagekit.io/vernu/textbee/textbee.dev-landingpage-screenshot.png?updatedAt=1749102564772)
## Features
@@ -25,7 +25,7 @@ TextBee is an open-source SMS gateway that enables users to send and receive SMS
## Getting Started
1. Go to [textbee.dev](https://textbee.dev) and register or login with your account
2. Install the app on your android phone from [dl.textbee.dev](https://dl.textbee.dev)
2. Install the app on your android phone from [textbee.dev/download](https://textbee.dev/download)
3. Open the app and grant the permissions for SMS
4. Go to [textbee.dev/dashboard](https://textbee.dev/dashboard) and click register device/ generate API Key
5. Scan the QR code with the app or enter the API key manually
+7 -3
View File
@@ -1,6 +1,7 @@
plugins {
id 'com.android.application'
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
}
android {
@@ -10,8 +11,8 @@ android {
applicationId "com.vernu.sms"
minSdk 24
targetSdk 32
versionCode 12
versionName "2.5.0"
versionCode 15
versionName "2.6.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -37,7 +38,7 @@ android {
dependencies {
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'com.google.android.material:material:1.8.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
@@ -47,11 +48,14 @@ dependencies {
implementation 'com.google.firebase:firebase-analytics'
implementation 'com.google.firebase:firebase-messaging'
implementation 'com.google.firebase:firebase-messaging-directboot'
implementation 'com.google.firebase:firebase-crashlytics'
implementation 'com.google.code.gson:gson:2.9.0'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.journeyapps:zxing-android-embedded:4.1.0'
implementation 'androidx.work:work-runtime:2.7.1'
// def room_version = "2.4.2"
// implementation "androidx.room:room-runtime:$room_version"
+19 -1
View File
@@ -14,7 +14,9 @@
<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"/>
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".SMSGatewayApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -22,6 +24,12 @@
android:supportsRtl="true"
android:theme="@style/Theme.SMSGateway"
android:usesCleartextTraffic="true" >
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<service
android:name=".services.FCMService"
android:exported="false"
@@ -44,6 +52,15 @@
</intent-filter>
</receiver>
<receiver
android:name=".receivers.SMSStatusReceiver"
android:exported="true">
<intent-filter>
<action android:name="SMS_SENT" />
<action android:name="SMS_DELIVERED" />
</intent-filter>
</receiver>
<receiver android:enabled="true"
android:name=".receivers.BootCompletedReceiver"
android:exported="true"
@@ -55,7 +72,8 @@
<activity
android:name=".activities.MainActivity"
android:exported="true">
android:exported="true"
android:theme="@style/Theme.SMSGateway.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 131 KiB

@@ -16,4 +16,7 @@ public class AppConstants {
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";
public static final String SHARED_PREFS_LAST_VERSION_CODE_KEY = "LAST_VERSION_CODE";
public static final String SHARED_PREFS_LAST_VERSION_NAME_KEY = "LAST_VERSION_NAME";
public static final String SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY = "STICKY_NOTIFICATION_ENABLED";
}
@@ -0,0 +1,20 @@
package com.vernu.sms;
import android.app.Application;
import androidx.work.Configuration;
import androidx.work.WorkManager;
public class SMSGatewayApplication extends Application implements Configuration.Provider {
@Override
public void onCreate() {
super.onCreate();
}
@Override
public Configuration getWorkManagerConfiguration() {
return new Configuration.Builder()
.setMinimumLoggingLevel(android.util.Log.INFO)
.build();
}
}
@@ -7,16 +7,22 @@ import android.content.pm.PackageManager;
import android.os.Build;
import android.telephony.SubscriptionInfo;
import android.telephony.SubscriptionManager;
import android.util.Log;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.google.firebase.crashlytics.FirebaseCrashlytics;
import com.vernu.sms.services.StickyNotificationService;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class TextBeeUtils {
private static final String TAG = "TextBeeUtils";
public static boolean isPermissionGranted(Context context, String permission) {
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED;
}
@@ -33,21 +39,82 @@ public class TextBeeUtils {
}
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);
// Only start service if user has enabled sticky notification
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY,
false
);
if (stickyNotificationEnabled) {
Intent notificationIntent = new Intent(context, StickyNotificationService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(notificationIntent);
} else {
context.startService(notificationIntent);
}
Log.i(TAG, "Starting sticky notification service");
} else {
context.startService(notificationIntent);
Log.i(TAG, "Sticky notification disabled by user, not starting service");
}
}
public static void stopStickyNotificationService(Context context) {
Intent notificationIntent = new Intent(context, StickyNotificationService.class);
context.stopService(notificationIntent);
Log.i(TAG, "Stopping sticky notification service");
}
/**
* Log a non-fatal exception to Crashlytics with additional context information
*
* @param throwable The exception to log
* @param message A message describing what happened
* @param customData Optional map of custom key-value pairs to add as context
*/
public static void logException(Throwable throwable, String message, Map<String, Object> customData) {
try {
Log.e(TAG, message, throwable);
FirebaseCrashlytics crashlytics = FirebaseCrashlytics.getInstance();
crashlytics.log(message);
// Add any custom data as key-value pairs
if (customData != null) {
for (Map.Entry<String, Object> entry : customData.entrySet()) {
if (entry.getValue() instanceof String) {
crashlytics.setCustomKey(entry.getKey(), (String) entry.getValue());
} else if (entry.getValue() instanceof Boolean) {
crashlytics.setCustomKey(entry.getKey(), (Boolean) entry.getValue());
} else if (entry.getValue() instanceof Integer) {
crashlytics.setCustomKey(entry.getKey(), (Integer) entry.getValue());
} else if (entry.getValue() instanceof Long) {
crashlytics.setCustomKey(entry.getKey(), (Long) entry.getValue());
} else if (entry.getValue() instanceof Float) {
crashlytics.setCustomKey(entry.getKey(), (Float) entry.getValue());
} else if (entry.getValue() instanceof Double) {
crashlytics.setCustomKey(entry.getKey(), (Double) entry.getValue());
} else if (entry.getValue() != null) {
crashlytics.setCustomKey(entry.getKey(), entry.getValue().toString());
}
}
}
// Record the exception
crashlytics.recordException(throwable);
} catch (Exception e) {
Log.e(TAG, "Error logging exception to Crashlytics", e);
}
}
/**
* Simplified method to log a non-fatal exception with just a message
*/
public static void logException(Throwable throwable, String message) {
logException(throwable, message, null);
}
}
@@ -31,6 +31,8 @@ import com.vernu.sms.R;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.helpers.VersionTracker;
import com.google.firebase.crashlytics.FirebaseCrashlytics;
import java.util.Arrays;
import java.util.Objects;
import retrofit2.Call;
@@ -40,11 +42,11 @@ import retrofit2.Response;
public class MainActivity extends AppCompatActivity {
private Context mContext;
private Switch gatewaySwitch, receiveSMSSwitch;
private EditText apiKeyEditText, fcmTokenEditText;
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn;
private Switch gatewaySwitch, receiveSMSSwitch, stickyNotificationSwitch;
private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText;
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn;
private ImageButton copyDeviceIdImgBtn;
private TextView deviceBrandAndModelTxt, deviceIdTxt;
private TextView deviceBrandAndModelTxt, deviceIdTxt, appVersionNameTxt, appVersionCodeTxt;
private RadioGroup defaultSimSlotRadioGroup;
private static final int SCAN_QR_REQUEST_CODE = 49374;
private static final int PERMISSION_REQUEST_CODE = 0;
@@ -60,8 +62,10 @@ public class MainActivity extends AppCompatActivity {
setContentView(R.layout.activity_main);
gatewaySwitch = findViewById(R.id.gatewaySwitch);
receiveSMSSwitch = findViewById(R.id.receiveSMSSwitch);
stickyNotificationSwitch = findViewById(R.id.stickyNotificationSwitch);
apiKeyEditText = findViewById(R.id.apiKeyEditText);
fcmTokenEditText = findViewById(R.id.fcmTokenEditText);
deviceIdEditText = findViewById(R.id.deviceIdEditText);
registerDeviceBtn = findViewById(R.id.registerDeviceBtn);
grantSMSPermissionBtn = findViewById(R.id.grantSMSPermissionBtn);
scanQRBtn = findViewById(R.id.scanQRButton);
@@ -69,9 +73,39 @@ public class MainActivity extends AppCompatActivity {
deviceIdTxt = findViewById(R.id.deviceIdTxt);
copyDeviceIdImgBtn = findViewById(R.id.copyDeviceIdImgBtn);
defaultSimSlotRadioGroup = findViewById(R.id.defaultSimSlotRadioGroup);
appVersionNameTxt = findViewById(R.id.appVersionNameTxt);
appVersionCodeTxt = findViewById(R.id.appVersionCodeTxt);
checkUpdatesBtn = findViewById(R.id.checkUpdatesBtn);
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
deviceBrandAndModelTxt.setText(Build.BRAND + " " + Build.MODEL);
// Set app version information
String versionName = BuildConfig.VERSION_NAME;
appVersionNameTxt.setText(versionName);
appVersionCodeTxt.setText(String.valueOf(BuildConfig.VERSION_CODE));
// Check for app version changes and report if needed
if (VersionTracker.hasVersionChanged(mContext)) {
Log.d(TAG, "App version changed or first launch, reporting to server");
VersionTracker.reportVersionToServer(mContext);
}
// Initialize Crashlytics with user information
FirebaseCrashlytics crashlytics = FirebaseCrashlytics.getInstance();
crashlytics.setCustomKey("device_id", deviceId != null ? deviceId : "not_registered");
crashlytics.setCustomKey("device_model", Build.MODEL);
crashlytics.setCustomKey("app_version", versionName);
crashlytics.setCustomKey("app_version_code", BuildConfig.VERSION_CODE);
// Start sticky notification service if enabled
boolean gatewayEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false);
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false);
if (gatewayEnabled && stickyNotificationEnabled) {
TextBeeUtils.startStickyNotificationService(mContext);
Log.d(TAG, "Starting sticky notification service on app start");
}
if (deviceId == null || deviceId.isEmpty()) {
registerDeviceBtn.setText("Register");
@@ -117,7 +151,7 @@ public class MainActivity extends AppCompatActivity {
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show();
Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
compoundButton.setEnabled(true);
return;
}
@@ -125,11 +159,14 @@ public class MainActivity extends AppCompatActivity {
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);
// }
if (enabled) {
// Check if sticky notification is enabled
if (SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false)) {
TextBeeUtils.startStickyNotificationService(mContext);
}
} else {
TextBeeUtils.stopStickyNotificationService(mContext);
}
compoundButton.setEnabled(true);
}
@Override
@@ -137,6 +174,7 @@ public class MainActivity extends AppCompatActivity {
Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show();
Log.e(TAG, "API_ERROR "+ t.getMessage());
Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage());
TextBeeUtils.logException(t, "Error updating device");
compoundButton.setEnabled(true);
}
});
@@ -150,6 +188,21 @@ public class MainActivity extends AppCompatActivity {
Snackbar.make(view, "Receive SMS " + (isCheked ? "enabled" : "disabled"), Snackbar.LENGTH_LONG).show();
});
// Setup sticky notification switch
stickyNotificationSwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false));
stickyNotificationSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> {
View view = compoundButton.getRootView();
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, isChecked);
if (isChecked) {
TextBeeUtils.startStickyNotificationService(mContext);
Snackbar.make(view, "Background service enabled - app will be more reliable", Snackbar.LENGTH_LONG).show();
} else {
TextBeeUtils.stopStickyNotificationService(mContext);
Snackbar.make(view, "Background service disabled - app may be killed when in background", Snackbar.LENGTH_LONG).show();
}
});
// TODO: check gateway status/api key/device validity and update UI accordingly
registerDeviceBtn.setOnClickListener(view -> {
String _deviceId = SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
@@ -165,29 +218,50 @@ public class MainActivity extends AppCompatActivity {
intentIntegrator.setRequestCode(SCAN_QR_REQUEST_CODE);
intentIntegrator.initiateScan();
});
checkUpdatesBtn.setOnClickListener(view -> {
String versionInfo = BuildConfig.VERSION_NAME + "(" + BuildConfig.VERSION_CODE + ")";
String encodedVersionInfo = android.net.Uri.encode(versionInfo);
String downloadUrl = "https://textbee.dev/download?currentVersion=" + encodedVersionInfo;
Intent browserIntent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(downloadUrl));
startActivity(browserIntent);
});
}
private void renderAvailableSimOptions() {
try {
defaultSimSlotRadioGroup.removeAllViews();
// Set radio group styling for dark mode compatibility
defaultSimSlotRadioGroup.setBackgroundColor(getResources().getColor(R.color.background_secondary));
defaultSimSlotRadioGroup.setPadding(16, 8, 16, 8);
// Create the default radio button with proper styling
RadioButton defaultSimSlotRadioBtn = new RadioButton(mContext);
defaultSimSlotRadioBtn.setText("Device Default");
defaultSimSlotRadioBtn.setId((int)123456);
applyRadioButtonStyle(defaultSimSlotRadioBtn);
defaultSimSlotRadioGroup.addView(defaultSimSlotRadioBtn);
// Create radio buttons for each SIM with proper styling
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());
applyRadioButtonStyle(radioButton);
defaultSimSlotRadioGroup.addView(radioButton);
});
// Check the preferred SIM based on saved preferences
int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
if (preferredSim == -1) {
defaultSimSlotRadioGroup.check(defaultSimSlotRadioBtn.getId());
} else {
defaultSimSlotRadioGroup.check(preferredSim);
}
// Set the listener for SIM selection changes
defaultSimSlotRadioGroup.setOnCheckedChangeListener((radioGroup, i) -> {
RadioButton radioButton = findViewById(i);
if (radioButton == null) {
@@ -205,6 +279,42 @@ public class MainActivity extends AppCompatActivity {
Log.e(TAG, "SIM_SLOT_ERROR "+ e.getMessage());
}
}
/**
* Apply the custom radio button style to a programmatically created radio button
*/
private void applyRadioButtonStyle(RadioButton radioButton) {
// Set text color using the color state list for proper dark/light mode handling
setRadioButtonTextColor(radioButton);
// Set button tint for the radio circle
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
radioButton.setButtonTintList(getResources().getColorStateList(R.color.radio_button_tint, getTheme()));
} else {
radioButton.setButtonTintList(getResources().getColorStateList(R.color.radio_button_tint));
}
}
// Add proper padding for better touch experience
radioButton.setPadding(
radioButton.getPaddingLeft() + 8,
radioButton.getPaddingTop() + 12,
radioButton.getPaddingRight(),
radioButton.getPaddingBottom() + 12
);
}
/**
* Helper method to set radio button text color in a backward-compatible way
*/
private void setRadioButtonTextColor(RadioButton radioButton) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
radioButton.setTextColor(getResources().getColorStateList(R.color.radio_button_text_color, getTheme()));
} else {
radioButton.setTextColor(getResources().getColorStateList(R.color.radio_button_text_color));
}
}
@Override
public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
@@ -225,8 +335,9 @@ public class MainActivity extends AppCompatActivity {
}
private void handleRegisterDevice() {
String newKey = apiKeyEditText.getText().toString();
String deviceIdInput = deviceIdEditText.getText().toString();
registerDeviceBtn.setEnabled(false);
registerDeviceBtn.setText("Loading...");
View view = findViewById(R.id.registerDeviceBtn);
@@ -252,6 +363,53 @@ public class MainActivity extends AppCompatActivity {
registerDeviceInput.setOs(Build.VERSION.BASE_OS);
registerDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
registerDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
// If the user provided a device ID, use it for updating instead of creating new
if (!deviceIdInput.isEmpty()) {
Log.d(TAG, "Updating device with deviceId: "+ deviceIdInput);
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceIdInput, newKey, registerDeviceInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : 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 Updated Successfully :)", Snackbar.LENGTH_LONG).show();
// Update deviceId from response if available
if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) {
deviceId = response.body().data.get("_id").toString();
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
}
// Update stored version information
VersionTracker.updateStoredVersion(mContext);
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show();
Log.e(TAG, "API_ERROR "+ t.getMessage());
Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage());
TextBeeUtils.logException(t, "Error registering device");
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
});
return;
}
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().registerDevice(newKey, registerDeviceInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@@ -259,25 +417,35 @@ public class MainActivity extends AppCompatActivity {
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show();
Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : 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);
if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) {
deviceId = response.body().data.get("_id").toString();
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
}
// Update stored version information
VersionTracker.updateStoredVersion(mContext);
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show();
Log.e(TAG, "API_ERROR "+ t.getMessage());
Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage());
TextBeeUtils.logException(t, "Error registering device");
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
@@ -287,6 +455,9 @@ public class MainActivity extends AppCompatActivity {
private void handleUpdateDevice() {
String apiKey = apiKeyEditText.getText().toString();
String deviceIdInput = deviceIdEditText.getText().toString();
String deviceIdToUse = !deviceIdInput.isEmpty() ? deviceIdInput : deviceId;
registerDeviceBtn.setEnabled(false);
registerDeviceBtn.setText("Loading...");
View view = findViewById(R.id.registerDeviceBtn);
@@ -313,18 +484,30 @@ public class MainActivity extends AppCompatActivity {
updateDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
updateDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceId, apiKey, updateDeviceInput);
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceIdToUse, apiKey, updateDeviceInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show();
Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
return;
}
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, apiKey);
// Update deviceId from response if available
if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) {
deviceId = response.body().data.get("_id").toString();
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
}
// Update stored version information
VersionTracker.updateStoredVersion(mContext);
Snackbar.make(view, "Device Updated Successfully :)", Snackbar.LENGTH_LONG).show();
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
@@ -335,6 +518,7 @@ public class MainActivity extends AppCompatActivity {
Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show();
Log.e(TAG, "API_ERROR "+ t.getMessage());
Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage());
TextBeeUtils.logException(t, "Error updating device");
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
}
@@ -364,7 +548,11 @@ public class MainActivity extends AppCompatActivity {
}
String scannedQR = intentResult.getContents();
apiKeyEditText.setText(scannedQR);
handleRegisterDevice();
if(deviceIdEditText.getText().toString().isEmpty()) {
handleRegisterDevice();
} else {
handleUpdateDevice();
}
}
}
@@ -2,7 +2,7 @@ package com.vernu.sms.dtos;
public class RegisterDeviceInputDTO {
private String fcmToken;
private boolean enabled;
private Boolean enabled;
private String brand;
private String manufacturer;
private String model;
@@ -28,11 +28,11 @@ public class RegisterDeviceInputDTO {
this.fcmToken = fcmToken;
}
public boolean isEnabled() {
public Boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
@@ -7,6 +7,15 @@ public class SMSDTO {
private String message = "";
private long receivedAtInMillis;
private String smsId;
private String smsBatchId;
private String status;
private long sentAtInMillis;
private long deliveredAtInMillis;
private long failedAtInMillis;
private String errorCode;
private String errorMessage;
public SMSDTO() {
}
@@ -34,4 +43,68 @@ public class SMSDTO {
public void setReceivedAtInMillis(long receivedAtInMillis) {
this.receivedAtInMillis = receivedAtInMillis;
}
public String getSmsId() {
return smsId;
}
public void setSmsId(String smsId) {
this.smsId = smsId;
}
public String getSmsBatchId() {
return smsBatchId;
}
public void setSmsBatchId(String smsBatchId) {
this.smsBatchId = smsBatchId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public long getSentAtInMillis() {
return sentAtInMillis;
}
public void setSentAtInMillis(long sentAtInMillis) {
this.sentAtInMillis = sentAtInMillis;
}
public long getDeliveredAtInMillis() {
return deliveredAtInMillis;
}
public void setDeliveredAtInMillis(long deliveredAtInMillis) {
this.deliveredAtInMillis = deliveredAtInMillis;
}
public long getFailedAtInMillis() {
return failedAtInMillis;
}
public void setFailedAtInMillis(long failedAtInMillis) {
this.failedAtInMillis = failedAtInMillis;
}
public String getErrorCode() {
return errorCode;
}
public void setErrorCode(String errorCode) {
this.errorCode = errorCode;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
}
@@ -1,25 +1,236 @@
package com.vernu.sms.helpers;
import android.Manifest;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.telephony.SmsManager;
import android.os.Build;
import android.telephony.SubscriptionManager;
import android.util.Log;
import com.vernu.sms.ApiManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.dtos.SMSForwardResponseDTO;
import com.vernu.sms.receivers.SMSStatusReceiver;
import com.vernu.sms.services.GatewayApiService;
import java.util.ArrayList;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class SMSHelper {
public static void sendSMS(String phoneNo, String message) {
SmsManager smsManager = SmsManager.getDefault();
//for sms with more than 160 chars
ArrayList<String> parts = smsManager.divideMessage(message);
if (parts.size() > 1) {
smsManager.sendMultipartTextMessage(phoneNo, null, parts, null, null);
} else {
smsManager.sendTextMessage(phoneNo, null, message, null, null);
private static final String TAG = "SMSHelper";
/**
* Sends an SMS message and returns whether the operation was successful
*
* @param phoneNo The recipient's phone number
* @param message The SMS message to send
* @param smsId The unique ID for this SMS
* @param smsBatchId The batch ID for this SMS
* @param context The application context
* @return boolean True if sending was initiated, false if permissions aren't granted
*/
public static boolean sendSMS(String phoneNo, String message, String smsId, String smsBatchId, Context context) {
// Check if we have permission to send SMS
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS)) {
Log.e(TAG, "SMS permission not granted. Unable to send SMS.");
// Report failure to API
reportPermissionError(context, smsId, smsBatchId);
return false;
}
try {
SmsManager smsManager = SmsManager.getDefault();
// Create pending intents for status tracking
PendingIntent sentIntent = createSentPendingIntent(context, smsId, smsBatchId);
PendingIntent deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId);
// For SMS with more than 160 chars
ArrayList<String> parts = smsManager.divideMessage(message);
if (parts.size() > 1) {
ArrayList<PendingIntent> sentIntents = new ArrayList<>();
ArrayList<PendingIntent> deliveredIntents = new ArrayList<>();
for (int i = 0; i < parts.size(); i++) {
sentIntents.add(sentIntent);
deliveredIntents.add(deliveredIntent);
}
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents);
} else {
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent);
}
return true;
} catch (Exception e) {
Log.e(TAG, "Exception when sending SMS: " + e.getMessage());
// Report exception to API
reportSendingError(context, smsId, smsBatchId, e.getMessage());
return false;
}
}
/**
* Sends an SMS message from a specific SIM slot and returns whether the operation was successful
*
* @param phoneNo The recipient's phone number
* @param message The SMS message to send
* @param simSubscriptionId The specific SIM subscription ID to use
* @param smsId The unique ID for this SMS
* @param smsBatchId The batch ID for this SMS
* @param context The application context
* @return boolean True if sending was initiated, false if permissions aren't granted
*/
public static boolean sendSMSFromSpecificSim(String phoneNo, String message, int simSubscriptionId,
String smsId, String smsBatchId, Context context) {
// Check for required permissions
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS) ||
!TextBeeUtils.isPermissionGranted(context, Manifest.permission.READ_PHONE_STATE)) {
Log.e(TAG, "SMS or Phone State permission not granted. Unable to send SMS from specific SIM.");
// Report failure to API
reportPermissionError(context, smsId, smsBatchId);
return false;
}
try {
// Get the SmsManager for the specific SIM
SmsManager smsManager;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
smsManager = SmsManager.getSmsManagerForSubscriptionId(simSubscriptionId);
} else {
// Fallback to default SmsManager for older Android versions
smsManager = SmsManager.getDefault();
Log.w(TAG, "Using default SIM as specific SIM selection not supported on this Android version");
}
public static void sendSMSFromSpecificSim(String phoneNo, String message, int simSlot) {
SmsManager smsManager = SmsManager.getSmsManagerForSubscriptionId(simSlot);
smsManager.sendMultipartTextMessage(phoneNo, null, smsManager.divideMessage(message), null, null);
// Create pending intents for status tracking
PendingIntent sentIntent = createSentPendingIntent(context, smsId, smsBatchId);
PendingIntent deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId);
// For SMS with more than 160 chars
ArrayList<String> parts = smsManager.divideMessage(message);
if (parts.size() > 1) {
ArrayList<PendingIntent> sentIntents = new ArrayList<>();
ArrayList<PendingIntent> deliveredIntents = new ArrayList<>();
for (int i = 0; i < parts.size(); i++) {
sentIntents.add(sentIntent);
deliveredIntents.add(deliveredIntent);
}
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents);
} else {
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent);
}
return true;
} catch (Exception e) {
Log.e(TAG, "Exception when sending SMS from specific SIM: " + e.getMessage());
// Report exception to API
reportSendingError(context, smsId, smsBatchId, e.getMessage());
return false;
}
}
private static void reportPermissionError(Context context, String smsId, String smsBatchId) {
SMSDTO smsDTO = new SMSDTO();
smsDTO.setSmsId(smsId);
smsDTO.setSmsBatchId(smsBatchId);
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(System.currentTimeMillis());
smsDTO.setErrorCode("PERMISSION_DENIED");
smsDTO.setErrorMessage("SMS permission not granted");
updateSMSStatus(context, smsDTO);
}
private static void reportSendingError(Context context, String smsId, String smsBatchId, String errorMessage) {
SMSDTO smsDTO = new SMSDTO();
smsDTO.setSmsId(smsId);
smsDTO.setSmsBatchId(smsBatchId);
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(System.currentTimeMillis());
smsDTO.setErrorCode("SENDING_EXCEPTION");
smsDTO.setErrorMessage(errorMessage);
updateSMSStatus(context, smsDTO);
}
private static void updateSMSStatus(Context context, SMSDTO smsDTO) {
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
if (deviceId.isEmpty() || apiKey.isEmpty()) {
Log.e(TAG, "Device ID or API key not found");
return;
}
GatewayApiService apiService = ApiManager.getApiService();
Call<SMSForwardResponseDTO> call = apiService.updateSMSStatus(deviceId, apiKey, smsDTO);
call.enqueue(new Callback<SMSForwardResponseDTO>() {
@Override
public void onResponse(Call<SMSForwardResponseDTO> call, Response<SMSForwardResponseDTO> response) {
if (response.isSuccessful()) {
Log.d(TAG, "SMS status updated successfully - ID: " + smsDTO.getSmsId() + ", Status: " + smsDTO.getStatus());
} else {
Log.e(TAG, "Failed to update SMS status. Response code: " + response.code());
}
}
@Override
public void onFailure(Call<SMSForwardResponseDTO> call, Throwable t) {
Log.e(TAG, "API call failed: " + t.getMessage());
}
});
}
private static PendingIntent createSentPendingIntent(Context context, String smsId, String smsBatchId) {
// Create explicit intent (specify the component)
Intent intent = new Intent(context, SMSStatusReceiver.class);
intent.setAction(SMSStatusReceiver.SMS_SENT);
intent.putExtra("sms_id", smsId);
intent.putExtra("sms_batch_id", smsBatchId);
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags |= PendingIntent.FLAG_MUTABLE;
}
// Use a unique request code to avoid PendingIntent collisions
int requestCode = (smsId + "_sent").hashCode();
return PendingIntent.getBroadcast(context, requestCode, intent, flags);
}
private static PendingIntent createDeliveredPendingIntent(Context context, String smsId, String smsBatchId) {
// Create explicit intent (specify the component)
Intent intent = new Intent(context, SMSStatusReceiver.class);
intent.setAction(SMSStatusReceiver.SMS_DELIVERED);
intent.putExtra("sms_id", smsId);
intent.putExtra("sms_batch_id", smsBatchId);
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags |= PendingIntent.FLAG_MUTABLE;
}
// Use a unique request code to avoid PendingIntent collisions
int requestCode = (smsId + "_delivered").hashCode();
return PendingIntent.getBroadcast(context, requestCode, intent, flags);
}
}
@@ -0,0 +1,111 @@
package com.vernu.sms.helpers;
import android.content.Context;
import android.util.Log;
import com.vernu.sms.ApiManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.BuildConfig;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class VersionTracker {
private static final String TAG = "VersionTracker";
/**
* Checks if the app version has changed since the last time it was run
* @param context Application context
* @return true if version has changed, false otherwise
*/
public static boolean hasVersionChanged(Context context) {
int lastVersionCode = SharedPreferenceHelper.getSharedPreferenceInt(
context,
AppConstants.SHARED_PREFS_LAST_VERSION_CODE_KEY,
-1
);
String lastVersionName = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_LAST_VERSION_NAME_KEY,
""
);
int currentVersionCode = BuildConfig.VERSION_CODE;
String currentVersionName = BuildConfig.VERSION_NAME;
// First app launch or version changed
return lastVersionCode == -1 ||
lastVersionCode != currentVersionCode ||
!lastVersionName.equals(currentVersionName);
}
/**
* Updates the stored version information with current version
* @param context Application context
*/
public static void updateStoredVersion(Context context) {
SharedPreferenceHelper.setSharedPreferenceInt(
context,
AppConstants.SHARED_PREFS_LAST_VERSION_CODE_KEY,
BuildConfig.VERSION_CODE
);
SharedPreferenceHelper.setSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_LAST_VERSION_NAME_KEY,
BuildConfig.VERSION_NAME
);
}
/**
* Reports current app version to the server
* @param context Application context
*/
public static void reportVersionToServer(Context context) {
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
""
);
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_API_KEY_KEY,
""
);
// If device is not registered or no API key, can't report version
if (deviceId.isEmpty() || apiKey.isEmpty()) {
Log.d(TAG, "Can't report version: device not registered or no API key");
return;
}
RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
updateInput.setAppVersionCode(BuildConfig.VERSION_CODE);
updateInput.setAppVersionName(BuildConfig.VERSION_NAME);
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService()
.updateDevice(deviceId, apiKey, updateInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
if (response.isSuccessful()) {
Log.d(TAG, "Version update reported successfully");
updateStoredVersion(context);
} else {
Log.e(TAG, "Failed to report version update: " + response.code());
}
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Log.e(TAG, "Error reporting version update: " + t.getMessage());
}
});
}
}
@@ -4,6 +4,8 @@ public class SMSPayload {
private String[] recipients;
private String message;
private String smsId;
private String smsBatchId;
// Legacy fields that are no longer used
private String[] receivers;
@@ -27,4 +29,20 @@ public class SMSPayload {
public void setMessage(String message) {
this.message = message;
}
public String getSmsId() {
return smsId;
}
public void setSmsId(String smsId) {
this.smsId = smsId;
}
public String getSmsBatchId() {
return smsBatchId;
}
public void setSmsBatchId(String smsBatchId) {
this.smsBatchId = smsBatchId;
}
}
@@ -5,17 +5,96 @@ import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.util.Log;
import com.google.firebase.messaging.FirebaseMessaging;
import com.vernu.sms.ApiManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.BuildConfig;
import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.services.StickyNotificationService;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class BootCompletedReceiver extends BroadcastReceiver {
private static final String TAG = "BootCompletedReceiver";
@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);
// }
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY,
false
);
if(stickyNotificationEnabled && TextBeeUtils.isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)){
Log.i(TAG, "Device booted, starting sticky notification service");
TextBeeUtils.startStickyNotificationService(context);
}
// Report device info to server if device is registered
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
""
);
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_API_KEY_KEY,
""
);
// Only proceed if both device ID and API key are available
if (!deviceId.isEmpty() && !apiKey.isEmpty()) {
updateDeviceInfo(context, deviceId, apiKey);
}
}
}
/**
* Updates device information on the server after boot
*/
private void updateDeviceInfo(Context context, String deviceId, String apiKey) {
FirebaseMessaging.getInstance().getToken()
.addOnCompleteListener(task -> {
if (!task.isSuccessful()) {
Log.e(TAG, "Failed to obtain FCM token after boot");
return;
}
String token = task.getResult();
RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
updateInput.setFcmToken(token);
updateInput.setAppVersionCode(BuildConfig.VERSION_CODE);
updateInput.setAppVersionName(BuildConfig.VERSION_NAME);
Log.d(TAG, "Updating device info after boot - deviceId: " + deviceId);
ApiManager.getApiService()
.updateDevice(deviceId, apiKey, updateInput)
.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
if (response.isSuccessful()) {
Log.d(TAG, "Device info updated successfully after boot");
} else {
Log.e(TAG, "Failed to update device info after boot. Response code: " + response.code());
}
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Log.e(TAG, "Error updating device info after boot: " + t.getMessage());
}
});
});
}
}
@@ -6,17 +6,13 @@ 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 com.vernu.sms.workers.SMSReceivedWorker;
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";
@@ -64,32 +60,7 @@ public class SMSBroadcastReceiver extends BroadcastReceiver {
// 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);
}
});
SMSReceivedWorker.enqueueWork(context, deviceId, apiKey, receivedSMSDTO);
}
// private void updateLocalReceivedSMS(SMS localReceivedSMS, Context context) {
@@ -0,0 +1,158 @@
package com.vernu.sms.receivers;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.telephony.SmsManager;
import android.util.Log;
import com.vernu.sms.AppConstants;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.workers.SMSStatusUpdateWorker;
public class SMSStatusReceiver extends BroadcastReceiver {
private static final String TAG = "SMSStatusReceiver";
public static final String SMS_SENT = "SMS_SENT";
public static final String SMS_DELIVERED = "SMS_DELIVERED";
@Override
public void onReceive(Context context, Intent intent) {
String smsId = intent.getStringExtra("sms_id");
String smsBatchId = intent.getStringExtra("sms_batch_id");
String action = intent.getAction();
SMSDTO smsDTO = new SMSDTO();
smsDTO.setSmsId(smsId);
smsDTO.setSmsBatchId(smsBatchId);
if (SMS_SENT.equals(action)) {
handleSentStatus(context, getResultCode(), smsDTO);
} else if (SMS_DELIVERED.equals(action)) {
handleDeliveredStatus(context, getResultCode(), smsDTO);
}
}
private void handleSentStatus(Context context, int resultCode, SMSDTO smsDTO) {
long timestamp = System.currentTimeMillis();
String errorMessage = "";
switch (resultCode) {
case Activity.RESULT_OK:
smsDTO.setStatus("SENT");
smsDTO.setSentAtInMillis(timestamp);
Log.d(TAG, "SMS sent successfully - ID: " + smsDTO.getSmsId());
break;
case SmsManager.RESULT_ERROR_GENERIC_FAILURE:
errorMessage = "Generic failure";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_RADIO_OFF:
errorMessage = "Radio off";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_NULL_PDU:
errorMessage = "Null PDU";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_NO_SERVICE:
errorMessage = "No service";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_LIMIT_EXCEEDED:
errorMessage = "Sending limit exceeded";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_SHORT_CODE_NOT_ALLOWED:
errorMessage = "Short code not allowed";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_SHORT_CODE_NEVER_ALLOWED:
errorMessage = "Short code never allowed";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
default:
errorMessage = "Unknown error";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Unknown error code: " + resultCode);
break;
}
updateSMSStatus(context, smsDTO);
}
private void handleDeliveredStatus(Context context, int resultCode, SMSDTO smsDTO) {
long timestamp = System.currentTimeMillis();
String errorMessage = "";
switch (resultCode) {
case Activity.RESULT_OK:
smsDTO.setStatus("DELIVERED");
smsDTO.setDeliveredAtInMillis(timestamp);
Log.d(TAG, "SMS delivered successfully - ID: " + smsDTO.getSmsId());
break;
case Activity.RESULT_CANCELED:
errorMessage = "Delivery canceled";
smsDTO.setStatus("DELIVERY_FAILED");
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
default:
errorMessage = "Unknown delivery error";
smsDTO.setStatus("DELIVERY_FAILED");
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Unknown error code: " + resultCode);
break;
}
updateSMSStatus(context, smsDTO);
}
private void updateSMSStatus(Context context, SMSDTO smsDTO) {
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
if (deviceId.isEmpty() || apiKey.isEmpty()) {
Log.e(TAG, "Device ID or API key not found");
return;
}
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO);
}
}
@@ -19,6 +19,12 @@ import com.vernu.sms.activities.MainActivity;
import com.vernu.sms.helpers.SMSHelper;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.models.SMSPayload;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.ApiManager;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class FCMService extends FirebaseMessagingService {
@@ -27,33 +33,94 @@ public class FCMService extends FirebaseMessagingService {
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
Log.d(TAG, remoteMessage.getData().toString());
Gson gson = new Gson();
SMSPayload smsPayload = gson.fromJson(remoteMessage.getData().get("smsData"), SMSPayload.class);
try {
// Parse SMS payload data
Gson gson = new Gson();
SMSPayload smsPayload = gson.fromJson(remoteMessage.getData().get("smsData"), SMSPayload.class);
// Check if message contains a data payload.
if (remoteMessage.getData().size() > 0) {
int preferedSim = SharedPreferenceHelper.getSharedPreferenceInt(this, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
for (String receiver : smsPayload.getRecipients()) {
if(preferedSim == -1) {
SMSHelper.sendSMS(receiver, smsPayload.getMessage());
continue;
}
// Check if message contains a data payload
if (remoteMessage.getData().size() > 0) {
sendSMS(smsPayload);
}
// Handle any notification message
if (remoteMessage.getNotification() != null) {
// sendNotification("notif msg", "msg body");
}
} catch (Exception e) {
Log.e(TAG, "Error processing FCM message: " + e.getMessage());
}
}
/**
* Send SMS to recipients using the provided payload
*/
private void sendSMS(SMSPayload smsPayload) {
if (smsPayload == null) {
Log.e(TAG, "SMS payload is null");
return;
}
// Get preferred SIM
int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
this, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
// Check if SMS payload contains valid recipients
String[] recipients = smsPayload.getRecipients();
if (recipients == null || recipients.length == 0) {
Log.e(TAG, "No recipients found in SMS payload");
return;
}
// Send SMS to each recipient
boolean atLeastOneSent = false;
int sentCount = 0;
int failedCount = 0;
for (String recipient : recipients) {
boolean smsSent;
// Try to send using default or specific SIM based on preference
if (preferredSim == -1) {
// Use default SIM
smsSent = SMSHelper.sendSMS(
recipient,
smsPayload.getMessage(),
smsPayload.getSmsId(),
smsPayload.getSmsBatchId(),
this
);
} else {
// Use specific SIM
try {
SMSHelper.sendSMSFromSpecificSim(receiver, smsPayload.getMessage(), preferedSim);
} catch(Exception e) {
Log.d("SMS_SEND_ERROR", e.getMessage());
smsSent = SMSHelper.sendSMSFromSpecificSim(
recipient,
smsPayload.getMessage(),
preferredSim,
smsPayload.getSmsId(),
smsPayload.getSmsBatchId(),
this
);
} catch (Exception e) {
Log.e(TAG, "Error sending SMS from specific SIM: " + e.getMessage());
smsSent = false;
}
}
// Track sent and failed counts
if (smsSent) {
sentCount++;
atLeastOneSent = true;
} else {
failedCount++;
}
}
// TODO: Handle FCM Notification Messages
if (remoteMessage.getNotification() != null) {
// sendNotification("notif msg", "msg body");
}
// Log summary
Log.d(TAG, "SMS sending complete - Batch: " + smsPayload.getSmsBatchId() +
", Sent: " + sentCount + ", Failed: " + failedCount);
}
@Override
@@ -62,7 +129,39 @@ public class FCMService extends FirebaseMessagingService {
}
private void sendRegistrationToServer(String token) {
// Check if device ID and API key are saved in shared preferences
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(this, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
// Only proceed if both device ID and API key are available
if (deviceId.isEmpty() || apiKey.isEmpty()) {
Log.d(TAG, "Device ID or API key not available, skipping FCM token update");
return;
}
// Create update payload with new FCM token
RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
updateInput.setFcmToken(token);
// Call API to update the device with new token
Log.d(TAG, "Updating FCM token for device: " + deviceId);
ApiManager.getApiService()
.updateDevice(deviceId, apiKey, updateInput)
.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
if (response.isSuccessful()) {
Log.d(TAG, "FCM token updated successfully");
} else {
Log.e(TAG, "Failed to update FCM token. Response code: " + response.code());
}
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Log.e(TAG, "Error updating FCM token: " + t.getMessage());
}
});
}
/* build and show notification */
@@ -21,4 +21,7 @@ public interface GatewayApiService {
@POST("gateway/devices/{deviceId}/receive-sms")
Call<SMSForwardResponseDTO> sendReceivedSMS(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
@PATCH("gateway/devices/{deviceId}/sms-status")
Call<SMSForwardResponseDTO> updateSMSStatus(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
}
@@ -15,11 +15,12 @@ import androidx.core.app.NotificationCompat;
import com.vernu.sms.R;
import com.vernu.sms.activities.MainActivity;
import com.vernu.sms.receivers.SMSBroadcastReceiver;
import com.vernu.sms.AppConstants;
import com.vernu.sms.helpers.SharedPreferenceHelper;
public class StickyNotificationService extends Service {
private static final String TAG = "StickyNotificationService";
private final BroadcastReceiver receiver = new SMSBroadcastReceiver();
@Override
public IBinder onBind(Intent intent) {
@@ -32,15 +33,20 @@ public class StickyNotificationService extends Service {
super.onCreate();
Log.i(TAG, "Service Started");
// Only show notification if enabled in preferences
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
getApplicationContext(),
AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY,
false
);
// 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);
if (stickyNotificationEnabled) {
Notification notification = createNotification();
startForeground(1, notification);
Log.i(TAG, "Started foreground service with sticky notification");
} else {
Log.i(TAG, "Sticky notification disabled by user preference");
}
}
@Override
@@ -52,9 +58,8 @@ public class StickyNotificationService extends Service {
@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() {
@@ -72,10 +77,19 @@ public class StickyNotificationService extends Service {
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();
return builder.setContentTitle("TextBee Active")
.setContentText("SMS gateway service is active")
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSmallIcon(R.mipmap.ic_launcher)
.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();
return builder.setContentTitle("TextBee Active")
.setContentText("SMS gateway service is active")
.setOngoing(true)
.setSmallIcon(R.mipmap.ic_launcher)
.build();
}
}
@@ -0,0 +1,106 @@
package com.vernu.sms.workers;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import androidx.work.BackoffPolicy;
import androidx.work.Constraints;
import androidx.work.NetworkType;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import com.google.gson.Gson;
import com.vernu.sms.ApiManager;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.dtos.SMSForwardResponseDTO;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import retrofit2.Call;
import retrofit2.Response;
public class SMSReceivedWorker extends Worker {
private static final String TAG = "SMSReceivedWorker";
private static final int MAX_RETRIES = 5;
public static final String KEY_DEVICE_ID = "device_id";
public static final String KEY_API_KEY = "api_key";
public static final String KEY_SMS_DTO = "sms_dto";
public static final String KEY_RETRY_COUNT = "retry_count";
public SMSReceivedWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
String deviceId = getInputData().getString(KEY_DEVICE_ID);
String apiKey = getInputData().getString(KEY_API_KEY);
String smsDtoJson = getInputData().getString(KEY_SMS_DTO);
int retryCount = getInputData().getInt(KEY_RETRY_COUNT, 0);
if (deviceId == null || apiKey == null || smsDtoJson == null) {
Log.e(TAG, "Missing required parameters");
return Result.failure();
}
// Check if we've exceeded the maximum retry count
if (retryCount >= MAX_RETRIES) {
Log.e(TAG, "Maximum retry count reached for received SMS");
return Result.failure();
}
SMSDTO smsDTO = new Gson().fromJson(smsDtoJson, SMSDTO.class);
try {
Call<SMSForwardResponseDTO> call = ApiManager.getApiService().sendReceivedSMS(deviceId, apiKey, smsDTO);
Response<SMSForwardResponseDTO> response = call.execute();
if (response.isSuccessful()) {
Log.d(TAG, "Received SMS sent to server successfully");
return Result.success();
} else {
Log.e(TAG, "Failed to send received SMS to server. Response code: " + response.code());
return Result.retry();
}
} catch (IOException e) {
Log.e(TAG, "API call failed: " + e.getMessage());
return Result.retry();
}
}
public static void enqueueWork(Context context, String deviceId, String apiKey, SMSDTO smsDTO) {
Data inputData = new Data.Builder()
.putString(KEY_DEVICE_ID, deviceId)
.putString(KEY_API_KEY, apiKey)
.putString(KEY_SMS_DTO, new Gson().toJson(smsDTO))
.putInt(KEY_RETRY_COUNT, 0)
.build();
Constraints constraints = new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(SMSReceivedWorker.class)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.addTag("sms_received")
.build();
String uniqueWorkName = "sms_received_" + System.currentTimeMillis();
WorkManager.getInstance(context)
.beginUniqueWork(uniqueWorkName,
androidx.work.ExistingWorkPolicy.APPEND_OR_REPLACE,
workRequest)
.enqueue();
Log.d(TAG, "Work enqueued for received SMS from: " + smsDTO.getSender());
}
}
@@ -0,0 +1,105 @@
package com.vernu.sms.workers;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import androidx.work.BackoffPolicy;
import androidx.work.Constraints;
import androidx.work.NetworkType;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import com.google.gson.Gson;
import com.vernu.sms.ApiManager;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.dtos.SMSForwardResponseDTO;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import retrofit2.Call;
import retrofit2.Response;
public class SMSStatusUpdateWorker extends Worker {
private static final String TAG = "SMSStatusUpdateWorker";
private static final int MAX_RETRIES = 5;
public static final String KEY_DEVICE_ID = "device_id";
public static final String KEY_API_KEY = "api_key";
public static final String KEY_SMS_DTO = "sms_dto";
public static final String KEY_RETRY_COUNT = "retry_count";
public SMSStatusUpdateWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
String deviceId = getInputData().getString(KEY_DEVICE_ID);
String apiKey = getInputData().getString(KEY_API_KEY);
String smsDtoJson = getInputData().getString(KEY_SMS_DTO);
int retryCount = getInputData().getInt(KEY_RETRY_COUNT, 0);
if (deviceId == null || apiKey == null || smsDtoJson == null) {
Log.e(TAG, "Missing required parameters");
return Result.failure();
}
// Check if we've exceeded the maximum retry count
if (retryCount >= MAX_RETRIES) {
Log.e(TAG, "Maximum retry count reached for SMS status update");
return Result.failure();
}
SMSDTO smsDTO = new Gson().fromJson(smsDtoJson, SMSDTO.class);
try {
Call<SMSForwardResponseDTO> call = ApiManager.getApiService().updateSMSStatus(deviceId, apiKey, smsDTO);
Response<SMSForwardResponseDTO> response = call.execute();
if (response.isSuccessful()) {
Log.d(TAG, "SMS status updated successfully - ID: " + smsDTO.getSmsId() + ", Status: " + smsDTO.getStatus());
return Result.success();
} else {
Log.e(TAG, "Failed to update SMS status. Response code: " + response.code());
return Result.retry();
}
} catch (IOException e) {
Log.e(TAG, "API call failed: " + e.getMessage());
return Result.retry();
}
}
public static void enqueueWork(Context context, String deviceId, String apiKey, SMSDTO smsDTO) {
Data inputData = new Data.Builder()
.putString(KEY_DEVICE_ID, deviceId)
.putString(KEY_API_KEY, apiKey)
.putString(KEY_SMS_DTO, new Gson().toJson(smsDTO))
.putInt(KEY_RETRY_COUNT, 0)
.build();
Constraints constraints = new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(SMSStatusUpdateWorker.class)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.build();
String uniqueWorkName = "sms_status_" + smsDTO.getStatus() + "_" + System.currentTimeMillis();
WorkManager.getInstance(context)
.beginUniqueWork(uniqueWorkName,
androidx.work.ExistingWorkPolicy.REPLACE,
workRequest)
.enqueue();
Log.d(TAG, "Work enqueued for SMS status update - ID: " + smsDTO.getSmsId());
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="?attr/colorPrimary" />
<item android:state_enabled="false" android:color="@color/text_secondary" />
<item android:color="@color/text_primary" />
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="?attr/colorPrimary" />
<item android:state_enabled="false" android:color="@color/text_secondary" />
<item android:color="@color/text_secondary" />
</selector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"/>
</vector>
+564 -204
View File
@@ -1,164 +1,122 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background_primary"
tools:context=".activities.MainActivity">
<ScrollView
android:id="@+id/scrollView2"
<!-- Sticky Header Section -->
<LinearLayout
android:id="@+id/stickyHeader"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Header Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:background="?attr/colorPrimary"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enter your API key or scan the QR code below to get started"
android:textSize="20dp"
android:layout_margin="5dp"
android:textAlignment="center"
android:layout_gravity="center" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#ccccccee"
android:layout_margin="5dp"
android:orientation="horizontal">
android:text="textbee.dev - sms gateway"
android:textAlignment="center"
android:textColor="@color/white"
android:textSize="22sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:padding="5dp">
<EditText
android:id="@+id/apiKeyEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:hint="API Key"
android:inputType="text"
android:minHeight="48dp"
android:textIsSelectable="true" />
<EditText
android:id="@+id/fcmTokenEditText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="10"
android:enabled="false"
android:gravity="start|top"
android:hint="FCM Token"
android:inputType="textMultiLine"
android:visibility="gone" />
<Button
android:id="@+id/registerDeviceBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Register" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical">
<Button
android:id="@+id/scanQRButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:drawableTop="@drawable/ic_baseline_qr_code_24"
android:text="Scan"
android:textColor="@color/black"
android:theme="@style/Theme.Design.Light" />
</LinearLayout>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:text="Your ultimate solution for seamless SMS communication"
android:textAlignment="center"
android:textColor="@color/white"
android:textSize="12sp" />
</LinearLayout>
<!-- Device Info Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="-24dp"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="@color/background_secondary"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_margin="5dp"
android:orientation="horizontal">
android:orientation="horizontal"
android:padding="6dp">
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:padding="3dp"
android:src="@drawable/ic_baseline_phone_android_24"
android:tint="?attr/colorPrimary" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="5dp"
android:src="@drawable/ic_baseline_phone_android_24" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="3dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/deviceBrandAndModelTxt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Device Brand, Model"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical">
android:text="Device ID:"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Device ID"
android:textStyle="italic" />
<TextView
android:id="@+id/deviceIdTxt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ae5ce05c05cde3" />
</LinearLayout>
<TextView
android:id="@+id/deviceIdTxt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:text="ae5ce05c05cde3"
android:textColor="@color/text_primary"
android:textSize="12sp" />
<ImageButton
android:id="@+id/copyDeviceIdImgBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_baseline_content_copy_24" />
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="2dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:padding="2dp"
android:src="@drawable/ic_baseline_content_copy_24"
android:tint="?attr/colorPrimary" />
</LinearLayout>
</LinearLayout>
<LinearLayout
@@ -172,132 +130,534 @@
android:id="@+id/gatewaySwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:minHeight="32dp"
android:text="" />
android:minHeight="32dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Status"
android:textSize="16dp"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
<LinearLayout
<!-- Scrollable Content -->
<ScrollView
android:id="@+id/scrollView2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/stickyHeader">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- API Key Registration Card -->
<androidx.cardview.widget.CardView
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" />
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="@color/background_secondary"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="Account Information"
android:textColor="@color/text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<!-- Device ID Input Field -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="Device ID (optional)"
app:boxBackgroundColor="@android:color/transparent"
app:boxStrokeColor="?attr/colorPrimary"
app:hintTextColor="?attr/colorPrimary"
app:endIconMode="custom"
app:endIconDrawable="@drawable/ic_baseline_edit_24"
app:endIconTint="?attr/colorPrimary"
app:helperText="Leaving this field blank will register your device as new. If you have already registered this device previously, please enter the id here."
app:helperTextTextColor="@color/text_secondary">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/deviceIdEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:textColor="@color/text_primary"
android:textIsSelectable="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="API Key"
app:boxBackgroundColor="@android:color/transparent"
app:boxStrokeColor="?attr/colorPrimary"
app:hintTextColor="?attr/colorPrimary">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/apiKeyEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:textColor="@color/text_primary"
android:textIsSelectable="true" />
</com.google.android.material.textfield.TextInputLayout>
<EditText
android:id="@+id/fcmTokenEditText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="10"
android:enabled="false"
android:gravity="start|top"
android:hint="FCM Token"
android:inputType="textMultiLine"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<Button
android:id="@+id/registerDeviceBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="?attr/colorPrimary"
android:paddingHorizontal="24dp"
android:text="Connect"
android:textColor="@color/white" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<Button
android:id="@+id/scanQRButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:drawableLeft="@drawable/ic_baseline_qr_code_24"
android:text="Scan QR"
android:textColor="@color/black"
android:theme="@style/Theme.Design.Light" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Configuration Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="@color/background_secondary"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="Configuration"
android:textColor="@color/text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<!-- Permissions Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="SMS Permissions"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="REQUIRED for textbee to function!"
android:textColor="@android:color/holo_red_dark"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<Button
android:id="@+id/grantSMSPermissionBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="?attr/colorPrimary"
android:text="Grant SMS Permissions"
android:textColor="@color/white"
android:visibility="visible" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/divider"
android:layout_marginBottom="16dp" />
<!-- Receive SMS Toggle -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Receive SMS"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Toggle to enable SMS receiving"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
<Switch
android:id="@+id/receiveSMSSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="32dp" />
</LinearLayout>
<!-- Sticky Notification Setting -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sticky Notification"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@android:drawable/ic_dialog_info"
android:tint="?attr/colorPrimary"
android:layout_marginEnd="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Prevents app from being killed by the system (optional, but recommended for reliability)"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
<Switch
android:id="@+id/stickyNotificationSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="32dp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/divider"
android:layout_marginBottom="16dp" />
<!-- Default SIM Selection -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Default SIM"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="Select your preferred SIM for sending SMS"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<RadioGroup
android:id="@+id/defaultSimSlotRadioGroup"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"></RadioGroup>
android:orientation="vertical"
android:layout_marginStart="8dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<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
<!-- How To Use Card -->
<androidx.cardview.widget.CardView
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">
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="@color/background_secondary"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<TextView
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="How To Use"
android:textStyle="bold" />
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="How To Use"
android:textColor="@color/text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:background="?attr/colorPrimary"
android:gravity="center"
android:text="1"
android:textColor="@color/white"
android:textStyle="bold" />
<TextView
android:id="@+id/dashboardLinkText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Go to https://textbee.dev/dashboard"
android:textColor="?attr/colorPrimary"
android:autoLink="web"
android:linksClickable="true"
android:clickable="true"
android:focusable="true" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:background="?attr/colorPrimary"
android:gravity="center"
android:text="2"
android:textColor="@color/white"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Click 'Generate API Key / Get Started'"
android:textColor="@color/text_primary" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:background="?attr/colorPrimary"
android:gravity="center"
android:text="3"
android:textColor="@color/white"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Copy the API key or scan the QR code"
android:textColor="@color/text_primary" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- App Version Info Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="24dp"
app:cardBackgroundColor="@color/background_secondary"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
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" />
android:orientation="vertical"
android:padding="16dp">
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_horizontal">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_baseline_info_24"
android:tint="?attr/colorPrimary"
android:layout_marginEnd="12dp" />
<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="App Information"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="4dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Version: "
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<TextView
android:id="@+id/appVersionNameTxt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1.0.0"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=" (Build: "
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:layout_marginStart="4dp" />
<TextView
android:id="@+id/appVersionCodeTxt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="100"
android:textColor="@color/text_primary"
android:textSize="14sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=")"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
<Button
android:id="@+id/checkUpdatesBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="Check for Updates"
android:textColor="?attr/colorPrimary"
android:background="@android:color/transparent"
android:textAllCaps="false"
android:paddingHorizontal="0dp"
style="@style/Widget.AppCompat.Button.Borderless" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</ScrollView>
</LinearLayout>
</RelativeLayout>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- Dark mode specific colors -->
<color name="background_primary">#121212</color>
<color name="background_secondary">#1E1E1E</color>
<color name="text_primary">#FFFFFF</color>
<color name="text_secondary">#B3FFFFFF</color> <!-- 70% white -->
<color name="divider">#454545</color>
</resources>
@@ -2,8 +2,8 @@
<!-- Base application theme. -->
<style name="Theme.SMSGateway" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">#4299E1</item>
<item name="colorPrimaryVariant">#4299cc</item>
<item name="colorPrimary">#D97706</item>
<item name="colorPrimaryVariant">#B86504</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">#f35b04</item>
@@ -11,6 +11,33 @@
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
<!-- Dark mode specific overrides -->
<item name="android:textColorPrimary">@color/text_primary</item>
<item name="android:textColorSecondary">@color/text_secondary</item>
<item name="android:windowBackground">@color/background_primary</item>
<item name="android:colorBackground">@color/background_primary</item>
<item name="android:editTextColor">@color/text_primary</item>
<item name="android:textColorHint">@color/text_secondary</item>
</style>
<!-- Theme without action bar for MainActivity -->
<style name="Theme.SMSGateway.NoActionBar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">#D97706</item>
<item name="colorPrimaryVariant">#B86504</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">#f35b04</item>
<item name="colorSecondaryVariant">#f18701</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Dark mode specific overrides -->
<item name="android:textColorPrimary">@color/text_primary</item>
<item name="android:textColorSecondary">@color/text_secondary</item>
<item name="android:windowBackground">@color/background_primary</item>
<item name="android:colorBackground">@color/background_primary</item>
<item name="android:editTextColor">@color/text_primary</item>
<item name="android:textColorHint">@color/text_secondary</item>
</style>
</resources>
@@ -7,4 +7,11 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- Light mode specific colors -->
<color name="background_primary">#FFFFFF</color>
<color name="background_secondary">#F5F5F5</color>
<color name="text_primary">#000000</color>
<color name="text_secondary">#757575</color>
<color name="divider">#E0E0E0</color>
</resources>
+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">TextBee</string>
<string name="app_name">textbee.dev</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="RadioButtonStyle" parent="Widget.AppCompat.CompoundButton.RadioButton">
<item name="android:textColor">@color/radio_button_text_color</item>
<item name="android:paddingStart">8dp</item>
<item name="android:paddingTop">12dp</item>
<item name="android:paddingBottom">12dp</item>
<item name="android:buttonTint">?attr/colorPrimary</item>
</style>
</resources>
+16 -2
View File
@@ -2,8 +2,8 @@
<!-- Base application theme. -->
<style name="Theme.SMSGateway" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">#4299E1</item>
<item name="colorPrimaryVariant">#4299cc</item>
<item name="colorPrimary">#D97706</item>
<item name="colorPrimaryVariant">#B86504</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">#f35b04</item>
@@ -13,4 +13,18 @@
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
<!-- Theme without action bar for MainActivity -->
<style name="Theme.SMSGateway.NoActionBar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">#D97706</item>
<item name="colorPrimaryVariant">#B86504</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">#f35b04</item>
<item name="colorSecondaryVariant">#f18701</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
</style>
</resources>
+1
View File
@@ -3,6 +3,7 @@ plugins {
id 'com.android.application' version '7.1.2' apply false
id 'com.android.library' version '7.1.2' apply false
id 'com.google.gms.google-services' version '4.3.10' apply true
id 'com.google.firebase.crashlytics' version '2.9.9' apply false
}
task clean(type: Delete) {
+35
View File
@@ -25,6 +25,7 @@ import {
RetrieveSMSResponseDTO,
SendBulkSMSInputDTO,
SendSMSInputDTO,
UpdateSMSStatusDTO,
} from './gateway.dto'
import { GatewayService } from './gateway.service'
import { CanModifyDevice } from './guards/can-modify-device.guard'
@@ -149,4 +150,38 @@ export class GatewayController {
const result = await this.gatewayService.getMessages(deviceId, type, page, limit);
return result;
}
@ApiOperation({ summary: 'Update SMS status' })
@UseGuards(AuthGuard, CanModifyDevice)
@HttpCode(HttpStatus.OK)
@Patch('/devices/:id/sms-status')
async updateSMSStatus(
@Param('id') deviceId: string,
@Body() dto: UpdateSMSStatusDTO,
) {
const data = await this.gatewayService.updateSMSStatus(deviceId, dto);
return { data };
}
@ApiOperation({ summary: 'Get a single SMS by ID' })
@UseGuards(AuthGuard, CanModifyDevice)
@Get('/devices/:id/sms/:smsId')
async getSMSById(
@Param('id') deviceId: string,
@Param('smsId') smsId: string,
) {
const data = await this.gatewayService.getSMSById(deviceId, smsId);
return { data };
}
@ApiOperation({ summary: 'Get an SMS batch by ID with all its SMS messages' })
@UseGuards(AuthGuard, CanModifyDevice)
@Get('/devices/:id/sms-batch/:smsBatchId')
async getSmsBatchById(
@Param('id') deviceId: string,
@Param('smsBatchId') smsBatchId: string,
) {
const data = await this.gatewayService.getSmsBatchById(deviceId, smsBatchId);
return { data };
}
}
+59
View File
@@ -240,3 +240,62 @@ export class RetrieveSMSResponseDTO {
})
meta?: PaginationMetaDTO
}
export class UpdateSMSStatusDTO {
@ApiProperty({
type: String,
required: true,
description: 'The ID of the SMS',
})
smsId: string
@ApiProperty({
type: String,
required: true,
description: 'The ID of the SMS batch',
})
smsBatchId: string
@ApiProperty({
type: String,
required: true,
description: 'The status of the SMS (sent, delivered, failed)',
enum: ['sent', 'delivered', 'failed'],
})
status: string
@ApiProperty({
type: Number,
required: false,
description: 'The time the message was sent (in milliseconds)',
})
sentAtInMillis?: number
@ApiProperty({
type: Number,
required: false,
description: 'The time the message was delivered (in milliseconds)',
})
deliveredAtInMillis?: number
@ApiProperty({
type: Number,
required: false,
description: 'The time the message failed (in milliseconds)',
})
failedAtInMillis?: number
@ApiProperty({
type: String,
required: false,
description: 'Error code if the message failed',
})
errorCode?: string
@ApiProperty({
type: String,
required: false,
description: 'Error message if the message failed',
})
errorMessage?: string
}
+2 -1
View File
@@ -13,6 +13,7 @@ import { BullModule } from '@nestjs/bull'
import { ConfigModule } from '@nestjs/config'
import { SmsQueueService } from './queue/sms-queue.service'
import { SmsQueueProcessor } from './queue/sms-queue.processor'
import { SmsStatusUpdateTask } from './tasks/sms-status-update.task'
@Module({
imports: [
@@ -49,7 +50,7 @@ import { SmsQueueProcessor } from './queue/sms-queue.processor'
ConfigModule,
],
controllers: [GatewayController],
providers: [GatewayService, SmsQueueService, SmsQueueProcessor],
providers: [GatewayService, SmsQueueService, SmsQueueProcessor, SmsStatusUpdateTask],
exports: [MongooseModule, GatewayService, SmsQueueService],
})
export class GatewayModule {}
+173
View File
@@ -9,6 +9,7 @@ import {
RetrieveSMSDTO,
SendBulkSMSInputDTO,
SendSMSInputDTO,
UpdateSMSStatusDTO,
} from './gateway.dto'
import { User } from '../users/schemas/user.schema'
import { AuthService } from '../auth/auth.service'
@@ -76,6 +77,10 @@ export class GatewayService {
)
}
if (input.enabled !== false) {
input.enabled = true;
}
return await this.deviceModel.findByIdAndUpdate(
deviceId,
{ $set: input },
@@ -542,6 +547,7 @@ export class GatewayService {
device: device._id,
message: dto.message,
type: SMSType.RECEIVED,
status: 'received',
sender: dto.sender,
receivedAt,
})
@@ -690,6 +696,99 @@ export class GatewayService {
}
}
async updateSMSStatus(deviceId: string, dto: UpdateSMSStatusDTO): Promise<any> {
const device = await this.deviceModel.findById(deviceId);
if (!device) {
throw new HttpException(
{
success: false,
error: 'Device not found',
},
HttpStatus.NOT_FOUND,
);
}
const sms = await this.smsModel.findById(dto.smsId);
if (!sms) {
throw new HttpException(
{
success: false,
error: 'SMS not found',
},
HttpStatus.NOT_FOUND,
);
}
// Verify the SMS belongs to this device
if (sms.device.toString() !== deviceId) {
throw new HttpException(
{
success: false,
error: 'SMS does not belong to this device',
},
HttpStatus.FORBIDDEN,
);
}
// Normalize status to lowercase for comparison
const normalizedStatus = dto.status.toLowerCase();
const updateData: any = {
status: normalizedStatus, // Store normalized status
};
// Update timestamps based on status
if (normalizedStatus === 'sent' && dto.sentAtInMillis) {
updateData.sentAt = new Date(dto.sentAtInMillis);
} else if (normalizedStatus === 'delivered' && dto.deliveredAtInMillis) {
updateData.deliveredAt = new Date(dto.deliveredAtInMillis);
} else if (normalizedStatus === 'failed' && dto.failedAtInMillis) {
updateData.failedAt = new Date(dto.failedAtInMillis);
updateData.errorCode = dto.errorCode;
updateData.errorMessage = dto.errorMessage || 'Unknown error';
}
// Update the SMS
await this.smsModel.findByIdAndUpdate(dto.smsId, { $set: updateData });
// Check if all SMS in batch have the same status, then update batch status
if (dto.smsBatchId) {
const smsBatch = await this.smsBatchModel.findById(dto.smsBatchId);
if (smsBatch) {
const allSmsInBatch = await this.smsModel.find({ smsBatch: dto.smsBatchId });
// Check if all SMS in batch have the same status (case insensitive)
const allHaveSameStatus = allSmsInBatch.every(sms => sms.status.toLowerCase() === normalizedStatus);
if (allHaveSameStatus) {
const smsBatchStatus = normalizedStatus === 'failed' ? 'failed' : 'completed';
await this.smsBatchModel.findByIdAndUpdate(dto.smsBatchId, {
$set: { status: smsBatchStatus }
});
}
}
}
// Trigger webhook event for SMS status update
try {
this.webhookService.deliverNotification({
sms,
user: device.user,
event: WebhookEvent.SMS_STATUS_UPDATED,
});
} catch (error) {
console.error('Failed to trigger webhook event:', error);
}
return {
success: true,
message: 'SMS status updated successfully',
};
}
async getStatsForUser(user: User) {
const devices = await this.deviceModel.find({ user: user._id })
const apiKeys = await this.authService.getUserApiKeys(user)
@@ -728,4 +827,78 @@ export class GatewayService {
} others`
}
}
async getSMSById(deviceId: string, smsId: string): Promise<any> {
// Check if device exists and is enabled
const device = await this.deviceModel.findById(deviceId);
if (!device) {
throw new HttpException(
{
success: false,
error: 'Device not found',
},
HttpStatus.NOT_FOUND,
);
}
// Find the SMS that belongs to this device
const sms = await this.smsModel.findOne({
_id: smsId,
device: deviceId
});
if (!sms) {
throw new HttpException(
{
success: false,
error: 'SMS not found',
},
HttpStatus.NOT_FOUND,
);
}
return sms;
}
async getSmsBatchById(deviceId: string, smsBatchId: string): Promise<any> {
// Check if device exists
const device = await this.deviceModel.findById(deviceId);
if (!device) {
throw new HttpException(
{
success: false,
error: 'Device not found',
},
HttpStatus.NOT_FOUND,
);
}
// Find the SMS batch that belongs to this device
const smsBatch = await this.smsBatchModel.findOne({
_id: smsBatchId,
device: deviceId
});
if (!smsBatch) {
throw new HttpException(
{
success: false,
error: 'SMS batch not found',
},
HttpStatus.NOT_FOUND,
);
}
// Find all SMS messages that belong to this batch
const smsMessages = await this.smsModel.find({
smsBatch: smsBatchId,
device: deviceId
});
// Return both the batch and its SMS messages
return {
batch: smsBatch,
messages: smsMessages
};
}
}
+2 -1
View File
@@ -41,6 +41,7 @@ export class SmsQueueService {
batches.push(fcmMessages.slice(i, i + this.maxSmsBatchSize))
}
let delayMultiplier = 1;
for (const batch of batches) {
await this.smsQueue.add(
'send-sms',
@@ -52,7 +53,7 @@ export class SmsQueueService {
{
priority: 1, // TODO: Make this dynamic based on users subscription plan
attempts: 1,
delay: 1000, // 1 second
delay: 1000 * delayMultiplier++,
backoff: {
type: 'exponential',
delay: 5000, // 5 seconds
+2 -2
View File
@@ -32,8 +32,8 @@ export class SMSBatch {
@Prop({ type: Number, default: 0 })
failureCount: number
@Prop({ type: String, default: 'pending', enum: ['pending', 'processing', 'completed', 'partial_success', 'failed'] })
status: string
@Prop({ type: String, default: 'pending' })
status: 'pending' | 'processing' | 'completed' | 'partial_success' | 'failed'
@Prop({ type: String })
error: string
+8 -5
View File
@@ -49,15 +49,18 @@ export class SMS {
@Prop({ type: Date })
failedAt: Date
@Prop({ type: String, required: false })
errorCode: string
@Prop({ type: String, required: false })
errorMessage: string
// @Prop({ type: String })
// failureReason: string
@Prop({ type: String, default: 'pending', enum: ['pending', 'sent', 'delivered', 'failed'] })
status: string
@Prop({ type: String })
error: string
@Prop({ type: String, default: 'pending' })
status: 'pending' | 'sent' | 'delivered' | 'failed' | 'unknown' | 'received'
// misc metadata for debugging
@Prop({ type: Object })
@@ -0,0 +1,77 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { SmsStatusUpdateTask } from './sms-status-update.task';
import { SMS } from '../schemas/sms.schema';
import { SMSBatch } from '../schemas/sms-batch.schema';
import { Model } from 'mongoose';
describe('SmsStatusUpdateTask', () => {
let task: SmsStatusUpdateTask;
let smsModel: Model<SMS>;
let smsBatchModel: Model<SMSBatch>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SmsStatusUpdateTask,
{
provide: getModelToken(SMS.name),
useValue: {
updateMany: jest.fn().mockResolvedValue({ modifiedCount: 5 }),
},
},
{
provide: getModelToken(SMSBatch.name),
useValue: {
updateMany: jest.fn().mockResolvedValue({ modifiedCount: 2 }),
},
},
],
}).compile();
task = module.get<SmsStatusUpdateTask>(SmsStatusUpdateTask);
smsModel = module.get<Model<SMS>>(getModelToken(SMS.name));
smsBatchModel = module.get<Model<SMSBatch>>(getModelToken(SMSBatch.name));
});
it('should be defined', () => {
expect(task).toBeDefined();
});
describe('handlePendingSmsTimeout', () => {
it('should update stale pending SMS messages to unknown status', async () => {
jest.spyOn(smsModel, 'updateMany');
jest.spyOn(smsBatchModel, 'updateMany');
await task.handlePendingSmsTimeout();
// Check that SMS model was updated with correct query
expect(smsModel.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
status: 'pending',
requestedAt: expect.any(Object),
}),
{
$set: {
status: 'unknown',
errorMessage: 'Status update timeout - no response received after 20 minutes',
},
},
);
// Check that SMSBatch model was updated with correct query
expect(smsBatchModel.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
status: 'pending',
createdAt: expect.any(Object),
}),
{
$set: {
status: 'unknown',
error: 'Status update timeout - no response received after 20 minutes',
},
},
);
});
});
});
@@ -0,0 +1,68 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { SMS } from '../schemas/sms.schema';
import { SMSBatch } from '../schemas/sms-batch.schema';
@Injectable()
export class SmsStatusUpdateTask {
private readonly logger = new Logger(SmsStatusUpdateTask.name);
constructor(
@InjectModel(SMS.name) private smsModel: Model<SMS>,
@InjectModel(SMSBatch.name) private smsBatchModel: Model<SMSBatch>,
) {}
/**
* Cron job that runs every 5 minutes to update the status of SMS messages
* that have been pending for more than 20 minutes without any status updates.
*/
@Cron(CronExpression.EVERY_5_MINUTES)
async handlePendingSmsTimeout() {
this.logger.log('Running cron job to update stale pending SMS messages');
const twentyMinutesAgo = new Date();
twentyMinutesAgo.setMinutes(twentyMinutesAgo.getMinutes() - 20);
try {
const result = await this.smsModel.updateMany(
{
status: 'pending',
requestedAt: { $lt: twentyMinutesAgo },
},
{
$set: {
status: 'unknown',
errorMessage: 'Status update timeout - no response received after 20 minutes'
}
}
);
this.logger.log(`Updated ${result.modifiedCount} SMS messages from 'pending' to 'unknown' status`);
const batchResult = await this.smsBatchModel.updateMany(
{
status: 'pending',
createdAt: { $lt: twentyMinutesAgo }
},
{
$set: {
status: 'unknown',
error: 'Status update timeout - no response received after 20 minutes'
}
}
);
this.logger.log(`Updated ${batchResult.modifiedCount} SMS batches from 'pending' to 'unknown' status`);
} catch (error) {
this.logger.error('Error updating stale pending SMS messages', error);
}
}
}
+3 -1
View File
@@ -5,9 +5,10 @@ import { AppModule } from './app.module'
import * as firebase from 'firebase-admin'
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
import * as express from 'express'
import { NestExpressApplication } from '@nestjs/platform-express'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
const app: NestExpressApplication = await NestFactory.create(AppModule)
const PORT = process.env.PORT || 3001
app.setGlobalPrefix('api')
@@ -55,6 +56,7 @@ async function bootstrap() {
'/api/v1/billing/webhook/polar',
express.raw({ type: 'application/json' }),
)
app.useBodyParser('json', { limit: '2mb' });
app.enableCors()
await app.listen(PORT)
}
+1
View File
@@ -1,3 +1,4 @@
export enum WebhookEvent {
MESSAGE_RECEIVED = 'MESSAGE_RECEIVED',
SMS_STATUS_UPDATED = 'SMS_STATUS_UPDATED',
}
+3 -7
View File
@@ -29,8 +29,6 @@ COPY . .
# Set environment variables for building
ENV NEXT_TELEMETRY_DISABLED 1
# Generate prisma client - make sure it exists
RUN pnpm prisma generate
# Build the application
RUN pnpm build
@@ -59,14 +57,12 @@ COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy Prisma schema and generate client during runtime
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./
COPY --from=builder --chown=nextjs:nodejs /app/pnpm-lock.yaml ./
# Install only production dependencies, including Prisma, and generate Prisma client
RUN pnpm install --prod --frozen-lockfile && \
pnpm prisma generate
# Install only production dependencies
RUN pnpm install --prod --frozen-lockfile
# Switch to non-root user
USER nextjs
@@ -39,7 +39,8 @@ export default function LoginForm() {
const onSubmit = async (data: LoginFormValues) => {
try {
const result = await signIn('email-password-login', {
redirect: false,
redirect: true,
callbackUrl: Routes.dashboard,
email: data.email,
password: data.password,
})
@@ -49,8 +50,6 @@ export default function LoginForm() {
type: 'manual',
message: 'Invalid email or password',
})
} else {
router.push(Routes.dashboard)
}
} catch (error) {
console.error('login error:', error)
@@ -88,10 +87,10 @@ export default function LoginForm() {
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type='password'
{...field}
className='dark:text-white dark:bg-gray-800'
<Input
type='password'
{...field}
className='dark:text-white dark:bg-gray-800'
/>
</FormControl>
<FormMessage />
@@ -20,10 +20,10 @@ export default function LoginWithGoogle() {
variant: 'default',
})
await signIn('google-id-token-login', {
redirect: false,
redirect: true,
callbackUrl: redirect ? decodeURIComponent(redirect) : Routes.dashboard,
idToken: credentialResponse.credential,
})
router.push(redirect ? decodeURIComponent(redirect) : Routes.dashboard)
}
const onGoogleLoginError = () => {
@@ -123,7 +123,7 @@ export default function RequestPasswordResetForm() {
<CardFooter className='text-center'>
<Link
href={Routes.login}
className='text-sm text-blue-600 hover:underline'
className='text-sm text-brand-600 hover:underline'
>
Back to login
</Link>
@@ -188,7 +188,7 @@ export default function ResetPasswordForm({
<CardFooter className='text-center'>
<Link
href={Routes.login}
className='text-sm text-blue-600 hover:underline'
className='text-sm text-brand-600 hover:underline'
>
Back to login
</Link>
+2 -2
View File
@@ -50,7 +50,7 @@ export default function LoginPage() {
<CardFooter className='flex flex-col space-y-2 text-center'>
<Link
href={Routes.resetPassword}
className='text-sm text-blue-600 hover:underline'
className='text-sm text-brand-600 hover:underline'
>
Forgot your password?
</Link>
@@ -63,7 +63,7 @@ export default function LoginPage() {
redirect: redirect ? decodeURIComponent(redirect) : undefined,
},
}}
className='font-medium text-blue-600 hover:underline'
className='font-medium text-brand-600 hover:underline'
>
Sign up
</Link>
+1 -1
View File
@@ -56,7 +56,7 @@ export default function RegisterPage() {
redirect: redirect ? decodeURIComponent(redirect) : undefined,
},
}}
className='font-medium text-blue-600 hover:underline'
className='font-medium text-brand-600 hover:underline'
>
Sign in
</Link>
+3 -3
View File
@@ -40,8 +40,8 @@ const SuccessAlert = ({ title, message }: { title: string; message: string }) =>
)
const InfoAlert = ({ title, message }: { title: string; message: string }) => (
<Alert className='bg-blue-50 text-blue-700 border-blue-200'>
<Mail className='h-5 w-5 text-blue-600' />
<Alert className='bg-brand-50 text-brand-700 border-brand-200'>
<Mail className='h-5 w-5 text-brand-600' />
<AlertTitle className='text-lg font-semibold'>{title}</AlertTitle>
<AlertDescription>{message}</AlertDescription>
</Alert>
@@ -54,7 +54,7 @@ const LoadingSpinner = () => (
)
const DashboardButton = () => (
<Button className='w-full py-5 mt-2' asChild>
<Button className='w-full py-5 mt-2 text-white' asChild>
<Link href={Routes.dashboard}>
Go to Dashboard
<ArrowRight className='ml-2 h-5 w-5' />
+23 -20
View File
@@ -13,26 +13,29 @@ export default function CheckoutPage({ params }) {
const { data: session } = useSession()
const initiateCheckout = useCallback(async (retries = 2) => {
try {
const response = await httpBrowserClient.post('/billing/checkout', {
planName,
})
const initiateCheckout = useCallback(
async (retries = 2) => {
try {
const response = await httpBrowserClient.post('/billing/checkout', {
planName,
})
if (response.data?.redirectUrl) {
window.location.href = response.data?.redirectUrl
} else {
throw new Error('No redirect URL found')
if (response.data?.redirectUrl) {
window.location.href = response.data?.redirectUrl
} else {
throw new Error('No redirect URL found')
}
} catch (error) {
if (retries > 0) {
initiateCheckout(retries - 1)
} else {
setError('Failed to create checkout session. Please try again.')
console.error(error)
}
}
} catch (error) {
if (retries > 0) {
initiateCheckout(retries - 1)
} else {
setError('Failed to create checkout session. Please try again.')
console.error(error)
}
}
}, [planName])
},
[planName]
)
useEffect(() => {
initiateCheckout()
@@ -51,7 +54,7 @@ export default function CheckoutPage({ params }) {
setError(null)
initiateCheckout()
}}
className='mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600'
className='mt-4 px-4 py-2 bg-brand-500 text-white rounded hover:bg-brand-600'
>
Try Again
</button>
@@ -61,7 +64,7 @@ export default function CheckoutPage({ params }) {
return (
<div className='flex flex-col items-center justify-center min-h-[80vh] bg-gray-100 p-6 rounded-lg shadow-lg'>
<Loader className='animate-spin mb-4 text-blue-500' size={48} />
<Loader className='animate-spin mb-4 text-brand-500' size={48} />
<h2 className='text-2xl font-bold text-gray-800 mb-2'>Hang Tight!</h2>
<p className='text-lg text-gray-600 mb-4'>
We're processing your order. This won't take long!
@@ -218,7 +218,7 @@ export default function AccountSettings() {
if (isLoadingSubscription)
return (
<div className='flex justify-center items-center h-full'>
<div className='flex justify-center items-center h-full min-h-[200px] mt-10'>
<Spinner size='sm' />
</div>
)
@@ -230,22 +230,25 @@ export default function AccountSettings() {
)
// Format price with currency symbol
const formatPrice = (amount: number | null | undefined, currency: string | null | undefined) => {
if (amount == null || currency == null) return 'Free';
const formatPrice = (
amount: number | null | undefined,
currency: string | null | undefined
) => {
if (amount == null || currency == null) return 'Free'
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase() || 'USD',
minimumFractionDigits: 2,
});
return formatter.format(amount / 100);
};
})
return formatter.format(amount / 100)
}
const getBillingInterval = (interval: string | null | undefined) => {
if (!interval) return '';
return interval.toLowerCase() === 'month' ? 'monthly' : 'yearly';
};
if (!interval) return ''
return interval.toLowerCase() === 'month' ? 'monthly' : 'yearly'
}
return (
<div className='bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 border rounded-lg shadow p-4'>
@@ -259,40 +262,54 @@ export default function AccountSettings() {
Current subscription
</p>
{currentSubscription?.amount > 0 && (
<Badge variant="outline" className="text-xs font-medium">
{formatPrice(currentSubscription?.amount, currentSubscription?.currency)}
<Badge variant='outline' className='text-xs font-medium'>
{formatPrice(
currentSubscription?.amount,
currentSubscription?.currency
)}
{currentSubscription?.recurringInterval && (
<span className="ml-1">/ {getBillingInterval(currentSubscription?.recurringInterval)}</span>
<span className='ml-1'>
/{' '}
{getBillingInterval(
currentSubscription?.recurringInterval
)}
</span>
)}
</Badge>
)}
</div>
</div>
<div className={`flex items-center px-2 py-0.5 rounded-full ${
currentSubscription?.status === 'active'
? 'bg-green-50 dark:bg-green-900/30'
: currentSubscription?.status === 'past_due'
<div
className={`flex items-center px-2 py-0.5 rounded-full ${
currentSubscription?.status === 'active'
? 'bg-green-50 dark:bg-green-900/30'
: currentSubscription?.status === 'past_due'
? 'bg-amber-50 dark:bg-amber-900/30'
: 'bg-gray-50 dark:bg-gray-800/50'
}`}>
<Check className={`h-3 w-3 mr-1 ${
currentSubscription?.status === 'active'
? 'text-green-600 dark:text-green-400'
: currentSubscription?.status === 'past_due'
}`}
>
<Check
className={`h-3 w-3 mr-1 ${
currentSubscription?.status === 'active'
? 'text-green-600 dark:text-green-400'
: currentSubscription?.status === 'past_due'
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-600 dark:text-gray-400'
}`} />
<span className={`text-xs font-medium ${
currentSubscription?.status === 'active'
? 'text-green-600 dark:text-green-400'
: currentSubscription?.status === 'past_due'
}`}
/>
<span
className={`text-xs font-medium ${
currentSubscription?.status === 'active'
? 'text-green-600 dark:text-green-400'
: currentSubscription?.status === 'past_due'
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-600 dark:text-gray-400'
}`}>
{currentSubscription?.status
}`}
>
{currentSubscription?.status
? currentSubscription.status
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
: 'Active'}
</span>
@@ -301,7 +318,7 @@ export default function AccountSettings() {
<div className='grid grid-cols-2 gap-3'>
<div className='flex items-center space-x-2 bg-white dark:bg-gray-800 p-2 rounded-md shadow-sm'>
<Calendar className='h-4 w-4 text-blue-600 dark:text-blue-400' />
<Calendar className='h-4 w-4 text-brand-600 dark:text-brand-400' />
<div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Start Date
@@ -321,7 +338,7 @@ export default function AccountSettings() {
</div>
<div className='flex items-center space-x-2 bg-white dark:bg-gray-800 p-2 rounded-md shadow-sm'>
<Calendar className='h-4 w-4 text-blue-600 dark:text-blue-400' />
<Calendar className='h-4 w-4 text-brand-600 dark:text-brand-400' />
<div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Next Payment
@@ -376,7 +393,8 @@ export default function AccountSettings() {
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.plan?.monthlyLimit === -1
? 'Unlimited'
: currentSubscription?.plan?.monthlyLimit?.toLocaleString() || '0'}
: currentSubscription?.plan?.monthlyLimit?.toLocaleString() ||
'0'}
{currentSubscription?.plan?.monthlyLimit === -1 && (
<TooltipProvider>
<Tooltip>
@@ -420,10 +438,11 @@ export default function AccountSettings() {
</div>
<div className='mt-4 flex justify-end gap-2'>
{(!currentSubscription?.plan?.name || currentSubscription?.plan?.name?.toLowerCase() === 'free') ? (
{!currentSubscription?.plan?.name ||
currentSubscription?.plan?.name?.toLowerCase() === 'free' ? (
<Link
href='/checkout/pro'
className='text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-md transition-colors'
className='text-xs font-medium text-white bg-brand-600 hover:bg-brand-700 px-3 py-1.5 rounded-md transition-colors'
>
Upgrade to Pro
</Link>
@@ -442,13 +461,13 @@ export default function AccountSettings() {
if (isLoadingUser)
return (
<div className='flex justify-center items-center h-full'>
<div className='flex justify-center items-center h-full min-h-[200px] mt-10'>
<Spinner size='sm' />
</div>
)
return (
<div className='grid gap-6 max-w-2xl mx-auto'>
<div className='grid gap-6 max-w-2xl mt-10'>
<CurrentSubscription />
<Card>
<CardHeader>
@@ -599,7 +618,7 @@ export default function AccountSettings() {
</div>
<div className='space-y-2'>
<Label htmlFor='oldPassword'>Old Password</Label>
<Label htmlFor='newPassword'>New Password</Label>
<Input
id='newPassword'
type='password'
@@ -714,9 +733,10 @@ export default function AccountSettings() {
{requestAccountDeletionError && (
<p className='text-sm text-destructive'>
{(requestAccountDeletionError as any).response?.data?.message ||
requestAccountDeletionError.message ||
'Failed to submit account deletion request'}
{(requestAccountDeletionError as any).response?.data
?.message ||
requestAccountDeletionError.message ||
'Failed to submit account deletion request'}
</p>
)}
@@ -129,7 +129,6 @@ export default function ApiKeys() {
<CardTitle className='text-lg'>API Keys</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className='h-[400px] pr-4'>
<div className='space-y-2'>
{isPending && (
<>
@@ -255,7 +254,6 @@ export default function ApiKeys() {
</Card>
))}
</div>
</ScrollArea>
{/* Revoke Dialog */}
<Dialog open={isRevokeDialogOpen} onOpenChange={setIsRevokeDialogOpen}>
@@ -143,7 +143,7 @@ export default function BulkSMSSend() {
const isStep3Disabled = isStep2Disabled || !selectedColumn || !messageTemplate
return (
<div className='container mx-auto p-4 space-y-8'>
<div className='space-y-8'>
<Card>
<CardHeader>
<CardTitle>Send Bulk SMS</CardTitle>
@@ -0,0 +1,155 @@
'use client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Loader2 } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useToast } from '@/hooks/use-toast'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useMutation } from '@tanstack/react-query'
import Link from 'next/link'
import { Routes } from '@/config/routes'
const changePasswordSchema = z
.object({
oldPassword: z.string().min(1, 'Old password is required'),
newPassword: z
.string()
.min(8, { message: 'Password must be at least 8 characters long' }),
confirmPassword: z
.string()
.min(4, { message: 'Please confirm your password' }),
})
.superRefine((data, ctx) => {
if (data.newPassword !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords must match',
path: ['confirmPassword'],
})
}
})
type ChangePasswordFormData = z.infer<typeof changePasswordSchema>
export default function ChangePasswordForm() {
const { toast } = useToast()
const changePasswordForm = useForm<ChangePasswordFormData>({
resolver: zodResolver(changePasswordSchema),
})
const {
mutate: changePassword,
isPending: isChangingPassword,
error: changePasswordError,
isSuccess: isChangePasswordSuccess,
} = useMutation({
mutationFn: (data: ChangePasswordFormData) =>
httpBrowserClient.post(ApiEndpoints.auth.changePassword(), data),
onSuccess: () => {
toast({
title: 'Password changed successfully!',
})
changePasswordForm.reset()
},
onError: (error) => {
const errorMessage = (error as any).response?.data?.error
changePasswordForm.setError('root.serverError', {
message: errorMessage || 'Failed to change password',
})
toast({
title: 'Failed to change password',
})
},
})
return (
<>
<p className='text-sm text-muted-foreground mb-4'>
If you signed in with Google, you can reset your password{' '}
<Link href={Routes.resetPassword} className='underline'>
here
</Link>
.
</p>
<form
onSubmit={changePasswordForm.handleSubmit((data) => changePassword(data))}
className='space-y-4'
>
<div className='space-y-2'>
<Label htmlFor='oldPassword'>Old Password</Label>
<Input
id='oldPassword'
type='password'
{...changePasswordForm.register('oldPassword')}
placeholder='Enter your old password'
/>
{changePasswordForm.formState.errors.oldPassword && (
<p className='text-sm text-destructive'>
{changePasswordForm.formState.errors.oldPassword.message}
</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='newPassword'>New Password</Label>
<Input
id='newPassword'
type='password'
{...changePasswordForm.register('newPassword')}
placeholder='Enter your new password'
/>
{changePasswordForm.formState.errors.newPassword && (
<p className='text-sm text-destructive'>
{changePasswordForm.formState.errors.newPassword.message}
</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='confirmPassword'>Confirm Password</Label>
<Input
id='confirmPassword'
type='password'
{...changePasswordForm.register('confirmPassword')}
placeholder='Enter your confirm password'
/>
{changePasswordForm.formState.errors.confirmPassword && (
<p className='text-sm text-destructive'>
{changePasswordForm.formState.errors.confirmPassword.message}
</p>
)}
</div>
{changePasswordForm.formState.errors.root?.serverError && (
<p className='text-sm text-destructive'>
{changePasswordForm.formState.errors.root.serverError.message}
</p>
)}
{isChangePasswordSuccess && (
<p className='text-sm text-green-500'>
Password changed successfully!
</p>
)}
<Button
type='submit'
className='w-full mt-6'
disabled={isChangingPassword}
>
{isChangingPassword ? (
<Loader2 className='h-4 w-4 animate-spin mr-2' />
) : null}
Change Password
</Button>
</form>
</>
)
}
@@ -6,8 +6,8 @@ import { ExternalLinks } from '@/config/external-links'
export default function CommunityLinks() {
return (
<div className='grid gap-4 md:grid-cols-4'>
<Card>
<div className='grid gap-4 md:grid-cols-2'>
{/* <Card>
<CardHeader>
<CardTitle>One-time Donation</CardTitle>
</CardHeader>
@@ -22,9 +22,9 @@ export default function CommunityLinks() {
</Button>
</Link>
</CardContent>
</Card>
</Card> */}
<Card>
{/* <Card>
<CardHeader>
<CardTitle>Support on Patreon</CardTitle>
</CardHeader>
@@ -39,7 +39,7 @@ export default function CommunityLinks() {
</Button>
</Link>
</CardContent>
</Card>
</Card> */}
<Card>
<CardHeader>
@@ -0,0 +1,165 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useMutation, useQuery } from '@tanstack/react-query'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
export default function DeleteAccountForm() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [deleteConfirmEmail, setDeleteConfirmEmail] = useState('')
const [deleteReason, setDeleteReason] = useState('')
const { toast } = useToast()
const { data: currentUser } = useQuery({
queryKey: ['currentUser'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.whoAmI())
.then((res) => res.data?.data),
})
const handleDeleteAccount = () => {
if (deleteConfirmEmail !== currentUser?.email) {
toast({
title: 'Please enter your correct email address',
})
return
} else if (deleteReason.length < 4) {
toast({
title: 'Please enter a reason for deletion',
})
return
}
requestAccountDeletion()
}
const {
mutate: requestAccountDeletion,
isPending: isRequestingAccountDeletion,
error: requestAccountDeletionError,
isSuccess: isRequestAccountDeletionSuccess,
} = useMutation({
mutationFn: () =>
httpBrowserClient.post(ApiEndpoints.support.requestAccountDeletion(), {
message: deleteReason,
}),
onSuccess: () => {
toast({
title: 'Account deletion request submitted',
})
setIsDeleteDialogOpen(false)
},
onError: () => {
toast({
title: 'Failed to submit account deletion request',
})
},
})
return (
<>
<p className='text-sm text-muted-foreground mb-6'>
Once you delete your account, there is no going back. This action
permanently removes all your data, cancels subscriptions, and revokes
access to all services.
</p>
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<Button
variant='destructive'
className='w-full'
onClick={() => setIsDeleteDialogOpen(true)}
>
<AlertTriangle className='mr-2 h-4 w-4' />
Delete Account
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<AlertTriangle className='h-5 w-5 text-destructive' />
Delete Account
</DialogTitle>
<DialogDescription className='pt-4'>
<p className='mb-4'>
Are you sure you want to delete your account? This action:
</p>
<ul className='list-disc list-inside space-y-2 mb-4'>
<li>Cannot be undone</li>
<li>Will permanently delete all your data</li>
<li>Will cancel all active subscriptions</li>
<li>Will remove access to all services</li>
</ul>
<Label htmlFor='deleteReason'>Reason for deletion</Label>
<Textarea
className='my-2'
placeholder='Enter your reason for deletion'
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
/>
<p>Please type your email address to confirm:</p>
<Input
className='mt-2'
placeholder='Enter your email address'
value={deleteConfirmEmail}
onChange={(e) => setDeleteConfirmEmail(e.target.value)}
/>
{requestAccountDeletionError && (
<p className='text-sm text-destructive'>
{(requestAccountDeletionError as any).response?.data
?.message ||
requestAccountDeletionError.message ||
'Failed to submit account deletion request'}
</p>
)}
{isRequestAccountDeletionSuccess && (
<p className='text-sm text-green-500'>
Account deletion request submitted
</p>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className='gap-2 sm:gap-0'>
<Button
variant='outline'
onClick={() => setIsDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button
variant='destructive'
onClick={handleDeleteAccount}
disabled={isRequestingAccountDeletion || !deleteConfirmEmail}
>
{isRequestingAccountDeletion ? (
<Loader2 className='h-4 w-4 animate-spin mr-2' />
) : (
<AlertTriangle className='h-4 w-4 mr-2' />
)}
Delete Account
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -39,7 +39,6 @@ export default function DeviceList() {
<CardTitle className='text-lg'>Registered Devices</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className='h-[400px] pr-4'>
<div className='space-y-2'>
{isPending && (
<>
@@ -129,7 +128,6 @@ export default function DeviceList() {
</Card>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
)
@@ -0,0 +1,192 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Shield, Loader2, Mail, Check, UserCircle } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useToast } from '@/hooks/use-toast'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useMutation, useQuery } from '@tanstack/react-query'
import { Spinner } from '@/components/ui/spinner'
import { useSession } from 'next-auth/react'
const updateProfileSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email().optional(),
phone: z
.string()
.regex(/^\+?\d{0,14}$/, 'Invalid phone number')
.optional(),
})
type UpdateProfileFormData = z.infer<typeof updateProfileSchema>
export default function EditProfileForm() {
const { toast } = useToast()
const { update: updateSession } = useSession()
const {
data: currentUser,
isLoading: isLoadingUser,
refetch: refetchCurrentUser,
} = useQuery({
queryKey: ['currentUser'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.auth.whoAmI())
.then((res) => res.data?.data),
})
const updateProfileForm = useForm<UpdateProfileFormData>({
resolver: zodResolver(updateProfileSchema),
defaultValues: {
name: currentUser?.name,
email: currentUser?.email,
phone: currentUser?.phone,
},
})
const handleVerifyEmail = () => {
// TODO: Implement email verification
}
const {
mutate: updateProfile,
isPending: isUpdatingProfile,
error: updateProfileError,
isSuccess: isUpdateProfileSuccess,
} = useMutation({
mutationFn: (data: UpdateProfileFormData) =>
httpBrowserClient.patch(ApiEndpoints.auth.updateProfile(), data),
onSuccess: () => {
refetchCurrentUser()
toast({
title: 'Profile updated successfully!',
})
updateSession({
name: updateProfileForm.getValues().name,
phone: updateProfileForm.getValues().phone,
})
},
onError: () => {
toast({
title: 'Failed to update profile',
})
},
})
if (isLoadingUser)
return (
<div className='flex justify-center items-center h-full min-h-[200px]'>
<Spinner size='sm' />
</div>
)
return (
<form
onSubmit={updateProfileForm.handleSubmit((data) => updateProfile(data))}
className='space-y-4'
>
<div className='space-y-2'>
<Label htmlFor='name'>Full Name</Label>
<Input
id='name'
{...updateProfileForm.register('name')}
placeholder='Enter your full name'
defaultValue={currentUser?.name}
/>
{updateProfileForm.formState.errors.name && (
<p className='text-sm text-destructive'>
{updateProfileForm.formState.errors.name.message}
</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='email' className='flex items-center gap-2'>
Email Address
{currentUser?.emailVerifiedAt && (
<Badge variant='secondary' className='ml-2'>
<Shield className='h-3 w-3 mr-1' />
Verified
</Badge>
)}
</Label>
<div className='flex gap-2'>
<Input
id='email'
type='email'
{...updateProfileForm.register('email')}
placeholder='Enter your email'
defaultValue={currentUser?.email}
disabled
/>
{!currentUser?.emailVerifiedAt ? (
<Button
type='button'
variant='outline'
onClick={handleVerifyEmail}
disabled={true}
>
{isUpdatingProfile ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<Mail className='h-4 w-4 mr-2' />
)}
Verify
</Button>
) : (
<Button variant='outline' disabled>
<Check className='h-4 w-4 mr-2' />
Verified
</Button>
)}
</div>
{updateProfileForm.formState.errors.email && (
<p className='text-sm text-destructive'>
{updateProfileForm.formState.errors.email.message}
</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='phone'>Phone Number</Label>
<Input
id='phone'
type='tel'
{...updateProfileForm.register('phone')}
placeholder='Enter your phone number'
defaultValue={currentUser?.phone}
/>
{updateProfileForm.formState.errors.phone && (
<p className='text-sm text-destructive'>
{updateProfileForm.formState.errors.phone.message}
</p>
)}
</div>
{isUpdateProfileSuccess && (
<p className='text-sm text-green-500'>
Profile updated successfully!
</p>
)}
<Button
type='submit'
className='w-full mt-6'
disabled={isUpdatingProfile}
>
{isUpdatingProfile ? (
<Loader2 className='h-4 w-4 animate-spin mr-2' />
) : null}
Save Changes
</Button>
</form>
)
}
@@ -1,9 +1,9 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { QrCode, Copy, AlertTriangle, Download, Smartphone } from 'lucide-react'
import { QrCode, Copy, AlertTriangle, Download, Smartphone, Lightbulb } from 'lucide-react'
import {
Dialog,
DialogContent,
@@ -23,7 +23,6 @@ export default function GetStartedCard() {
const [apiKey, setApiKey] = useState('')
const handleConfirmGenerateKey = () => {
setIsConfirmGenerateKeyModalOpen(true)
}
@@ -52,20 +51,53 @@ export default function GetStartedCard() {
return (
<>
<Card>
<CardHeader>
<CardTitle>Get Started</CardTitle>
<Card className="bg-gradient-to-br from-primary/10 to-background border-primary/20">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="rounded-full bg-primary/20 p-1.5">
<Lightbulb className="h-4 w-4 text-primary" />
</div>
<CardTitle>Quick Start Guide</CardTitle>
</div>
</div>
<CardDescription className="mt-2">
Complete these steps to start using TextBee SMS Gateway
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<p className='text-muted-foreground'>
To start using TextBee, you need to generate an API key and connect
your device.
</p>
<GenerateApiKey/>
<CardContent className="pt-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-start space-x-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-primary-foreground">
1
</div>
<div className="space-y-1">
<p className="font-medium">Download TextBee App</p>
<p className="text-sm text-muted-foreground">
Install the TextBee app on your Android device
</p>
<Button variant="outline" size="sm" className="mt-2" onClick={() => window.open('https://dl.textbee.dev', '_blank')}>
<Download className="mr-2 h-4 w-4" />
Download App APK
</Button>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/20 text-primary">
2
</div>
<div className="space-y-1">
<p className="font-medium">Generate API Key</p>
<p className="text-sm text-muted-foreground">
Create an API key to authenticate your requests
</p>
<GenerateApiKey />
</div>
</div>
</div>
</CardContent>
</Card>
</>
)
}
@@ -24,7 +24,7 @@ export default function DashboardOverview() {
onValueChange={handleTabChange}
className='space-y-4'
>
<TabsList className='sticky top-[4rem] z-10 flex mx-auto max-w-md border-[1px] my-6 bg-blue-500 text-white '>
<TabsList className='sticky top-[4rem] z-10 flex mx-auto max-w-md border-[1px] my-6 bg-brand-500 text-white '>
<TabsTrigger value='overview' className='flex-1'>
Overview
</TabsTrigger>
@@ -397,7 +397,143 @@ function FollowUpDialog({
)
}
function MessageCard({ message, type }) {
function StatusDetailsDialog({ message }: { message: any }) {
const [open, setOpen] = useState(false);
// Format timestamps for display
const formatTimestamp = (timestamp) => {
if (!timestamp) return 'N/A';
return new Date(timestamp).toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
// Get status badge color and icon based on message status
const getStatusBadge = () => {
const status = message.status || 'pending';
switch (status) {
case 'PENDING':
return {
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
icon: <Timer className="h-3 w-3 mr-1" />,
label: 'Pending'
};
case 'SENT':
return {
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
icon: <Check className="h-3 w-3 mr-1" />,
label: 'Sent'
};
case 'DELIVERED':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
icon: <Check className="h-3 w-3 mr-1" />,
label: 'Delivered'
};
case 'FAILED':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
icon: <X className="h-3 w-3 mr-1" />,
label: 'Failed'
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
icon: <Timer className="h-3 w-3 mr-1" />,
label: status
};
}
};
const statusBadge = getStatusBadge();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Badge variant="outline" className={`${statusBadge.color} flex items-center text-xs cursor-pointer`}>
{statusBadge.icon}
{statusBadge.label}
</Badge>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
SMS Status Details
</DialogTitle>
<DialogDescription>
Detailed information about this SMS message
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="font-medium">Status</div>
<div>{message.status || 'pending'}</div>
<div className="font-medium">Requested At</div>
<div>{formatTimestamp(message.requestedAt)}</div>
<div className="font-medium">Sent At</div>
<div>{formatTimestamp(message.sentAt)}</div>
<div className="font-medium">Delivered At</div>
<div>{formatTimestamp(message.deliveredAt)}</div>
{message.status === 'FAILED' && (
<>
<div className="font-medium">Failed At</div>
<div>{formatTimestamp(message.failedAt)}</div>
{message.errorCode && (
<>
<div className="font-medium">Error Code</div>
<div className="">{message.errorCode}</div>
</>
)}
{message.errorMessage && (
<>
<div className="font-medium">Error Message</div>
<div className="">{message.errorMessage}</div>
</>
)}
{(!message.errorCode && !message.errorMessage && message.error) && (
<>
<div className="font-medium">Error</div>
<div className="text-destructive">{message.error || 'Unknown error'}</div>
</>
)}
</>
)}
<div className="font-medium">Recipient</div>
<div>{message.recipient || (message.recipients && message.recipients[0]) || 'Unknown'}</div>
<div className="font-medium">Message ID</div>
<div className="font-mono text-xs">{message._id}</div>
{message.smsBatch && (
<>
<div className="font-medium">Batch ID</div>
<div className="font-mono text-xs">{message.smsBatch}</div>
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
function MessageCard({ message, type, device }) {
const isSent = type === 'sent'
const formattedDate = new Date(
@@ -410,11 +546,13 @@ function MessageCard({ message, type }) {
year: 'numeric',
})
const shouldShowStatus = device?.appVersionCode >= 14 && new Date(message?.createdAt) > new Date('2025-06-05')
return (
<Card
className={`hover:bg-muted/50 transition-colors max-w-sm md:max-w-none ${
isSent
? 'border-l-4 border-l-blue-500'
? 'border-l-4 border-l-brand-500'
: 'border-l-4 border-l-green-500'
}`}
>
@@ -423,7 +561,7 @@ function MessageCard({ message, type }) {
<div className='flex justify-between items-start'>
<div className='flex items-center gap-2'>
{isSent ? (
<div className='flex items-center text-blue-600 dark:text-blue-400 font-medium'>
<div className='flex items-center text-brand-600 dark:text-brand-400 font-medium'>
<ArrowUpRight className='h-4 w-4 mr-1' />
<span>
To:{' '}
@@ -449,17 +587,18 @@ function MessageCard({ message, type }) {
<p className='text-sm max-w-sm md:max-w-none'>{message.message}</p>
</div>
{!isSent && (
<div className='flex justify-end'>
<ReplyDialog sms={message} />
<div className='flex justify-between items-center'>
{isSent && shouldShowStatus && (
<div className='flex items-center'>
<StatusDetailsDialog message={message} />
</div>
)}
<div className='flex justify-end ml-auto'>
{!isSent && <ReplyDialog sms={message} />}
{isSent && <FollowUpDialog message={message} />}
</div>
)}
{isSent && (
<div className='flex justify-end'>
<FollowUpDialog message={message} />
</div>
)}
</div>
</div>
</CardContent>
</Card>
@@ -614,16 +753,16 @@ export default function MessageHistory() {
return (
<div className='space-y-4'>
<div className='bg-gradient-to-r from-blue-50 to-sky-50 dark:from-blue-950/30 dark:to-sky-950/30 rounded-lg shadow-sm border border-blue-100 dark:border-blue-800/50 p-4 mb-4'>
<div className='bg-gradient-to-r from-brand-50 to-sky-50 dark:from-brand-950/30 dark:to-sky-950/30 rounded-lg shadow-sm border border-brand-100 dark:border-brand-800/50 p-4 mb-4'>
<div className='flex flex-col gap-4'>
<div className='flex flex-col sm:flex-row sm:items-center gap-3'>
<div className='flex-1'>
<div className='flex items-center gap-2 mb-1.5'>
<Smartphone className='h-3.5 w-3.5 text-blue-500' />
<Smartphone className='h-3.5 w-3.5 text-brand-500' />
<h3 className='text-sm font-medium text-foreground'>Device</h3>
</div>
<Select value={currentDevice} onValueChange={handleDeviceChange}>
<SelectTrigger className='w-full bg-white/80 dark:bg-black/20 h-9 text-sm border-blue-200 dark:border-blue-800/70'>
<SelectTrigger className='w-full bg-white/80 dark:bg-black/20 h-9 text-sm border-brand-200 dark:border-brand-800/70'>
<SelectValue placeholder='Select a device' />
</SelectTrigger>
<SelectContent>
@@ -650,7 +789,7 @@ export default function MessageHistory() {
<div className='w-full sm:w-44'>
<div className='flex items-center gap-2 mb-1.5'>
<MessageSquare className='h-3.5 w-3.5 text-blue-500' />
<MessageSquare className='h-3.5 w-3.5 text-brand-500' />
<h3 className='text-sm font-medium text-foreground'>
Message Type
</h3>
@@ -659,7 +798,7 @@ export default function MessageHistory() {
value={messageType}
onValueChange={handleMessageTypeChange}
>
<SelectTrigger className='w-full bg-white/80 dark:bg-black/20 h-9 text-sm border-blue-200 dark:border-blue-800/70'>
<SelectTrigger className='w-full bg-white/80 dark:bg-black/20 h-9 text-sm border-brand-200 dark:border-brand-800/70'>
<SelectValue placeholder='Message type' />
</SelectTrigger>
<SelectContent>
@@ -677,7 +816,7 @@ export default function MessageHistory() {
</SelectItem>
<SelectItem value='sent'>
<div className='flex items-center gap-1.5'>
<div className='h-1.5 w-1.5 rounded-full bg-blue-500'></div>
<div className='h-1.5 w-1.5 rounded-full bg-brand-500'></div>
Sent
</div>
</SelectItem>
@@ -687,14 +826,14 @@ export default function MessageHistory() {
</div>
{/* Refresh Controls */}
<div className='flex items-center justify-between gap-2 pt-2 mt-2 border-t border-blue-100 dark:border-blue-800/50'>
<div className='flex items-center justify-between gap-2 pt-2 mt-2 border-t border-brand-100 dark:border-brand-800/50'>
<div className='flex items-center gap-1.5'>
<Button
onClick={handleRefresh}
variant='ghost'
size='sm'
disabled={!currentDevice}
className='h-7 px-2 text-xs text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/30'
className='h-7 px-2 text-xs text-brand-700 dark:text-brand-300 hover:bg-brand-100 dark:hover:bg-brand-900/30'
>
<RefreshCw
className={`h-3.5 w-3.5 mr-1 ${
@@ -712,7 +851,7 @@ export default function MessageHistory() {
</div>
<div className='flex items-center gap-1.5'>
<Timer className='h-3 w-3 text-blue-500' />
<Timer className='h-3 w-3 text-brand-500' />
<span className='text-xs font-medium mr-1'>Auto Refresh:</span>
<div className='flex'>
@@ -729,8 +868,8 @@ export default function MessageHistory() {
disabled={!currentDevice && interval.value > 0}
className={`h-6 px-1.5 text-xs ${
autoRefreshInterval === interval.value
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium'
: 'text-muted-foreground hover:bg-blue-50 dark:hover:bg-blue-900/20'
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 font-medium'
: 'text-muted-foreground hover:bg-brand-50 dark:hover:bg-brand-900/20'
}`}
onClick={() => setAutoRefreshInterval(interval.value)}
>
@@ -769,6 +908,7 @@ export default function MessageHistory() {
key={message._id}
message={message}
type={message.sender ? 'received' : 'sent'}
device={devices?.data?.find((device) => device._id === currentDevice)}
/>
))}
</div>
@@ -792,7 +932,7 @@ export default function MessageHistory() {
size='icon'
className={`h-8 w-8 rounded-full ${
page === 1
? 'bg-primary text-primary-foreground hover:bg-primary/90'
? 'bg-primary text-brand-foreground hover:bg-primary/90'
: 'hover:bg-secondary'
}`}
>
@@ -835,7 +975,7 @@ export default function MessageHistory() {
size='icon'
className={`h-8 w-8 rounded-full ${
page === pageToShow
? 'bg-primary text-primary-foreground hover:bg-primary/90'
? 'bg-primary text-brand-foreground hover:bg-primary/90'
: 'hover:bg-secondary'
}`}
>
@@ -860,7 +1000,7 @@ export default function MessageHistory() {
size='icon'
className={`h-8 w-8 rounded-full ${
page === pagination.totalPages
? 'bg-primary text-primary-foreground hover:bg-primary/90'
? 'bg-primary text-brand-foreground hover:bg-primary/90'
: 'hover:bg-secondary'
}`}
>
@@ -15,7 +15,7 @@ export default function Messaging() {
}
return (
<div className='grid gap-6 max-w-sm md:max-w-xl mx-auto mt-10'>
<div className='grid gap-6 w-full max-w-sm md:max-w-3xl'>
<Tabs
value={currentTab}
onValueChange={handleTabChange}
@@ -38,7 +38,7 @@ export default function Messaging() {
</TabsContent>
<TabsContent value='bulk-send' className='space-y-4'>
<div className='grid gap-6 max-w-xl mx-auto mt-10'>
<div className='grid gap-6 w-full'>
<BulkSMSSend />
</div>
</TabsContent>
@@ -1,7 +1,7 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BarChart3, Smartphone, Key, MessageSquare } from 'lucide-react'
import { BarChart3, Smartphone, Key, MessageSquare, TrendingUp } from 'lucide-react'
import GetStartedCard from './get-started'
import { ApiEndpoints } from '@/config/api'
import httpBrowserClient from '@/lib/httpBrowserClient'
@@ -11,14 +11,21 @@ import { Skeleton } from '@/components/ui/skeleton'
export const StatCard = ({ title, value, icon: Icon, description }) => {
return (
<Card>
<Card className="overflow-hidden transition-all hover:shadow-md">
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>{title}</CardTitle>
<Icon className='h-4 w-4 text-muted-foreground' />
<div className="rounded-full bg-primary/10 p-2">
<Icon className='h-4 w-4 text-primary' />
</div>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>{value ?? <Skeleton className='h-4 w-8 mb-2' />}</div>
<p className='text-xs text-muted-foreground'>{description}</p>
<div className='text-2xl font-bold'>
{value !== undefined ? value : <Skeleton className='h-6 w-16' />}
</div>
<p className='text-xs text-muted-foreground mt-1 flex items-center'>
{description}
{value !== undefined && <TrendingUp className="ml-1 h-3 w-3 text-green-500" />}
</p>
</CardContent>
</Card>
)
@@ -34,9 +41,9 @@ export default function Overview() {
})
return (
<div className='space-y-4'>
<div className='space-y-6'>
<GetStartedCard />
<div className='grid gap-4 grid-cols-2 lg:grid-cols-4'>
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
<StatCard
title='Total SMS Sent'
value={stats?.totalSentSMSCount?.toLocaleString()}
@@ -71,7 +71,7 @@ export default function SendSms() {
})
return (
<div className='grid gap-6 max-w-xl mx-auto mt-10'>
<div>
<Card>
<CardHeader>
<div className='flex items-center gap-2'>
@@ -0,0 +1,266 @@
'use client'
import { Calendar, Check, Info } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
import { useQuery } from '@tanstack/react-query'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import Link from 'next/link'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
export default function SubscriptionInfo() {
const {
data: currentSubscription,
isLoading: isLoadingSubscription,
error: subscriptionError,
} = useQuery({
queryKey: ['currentSubscription'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.billing.currentSubscription())
.then((res) => res.data),
})
// Format price with currency symbol
const formatPrice = (
amount: number | null | undefined,
currency: string | null | undefined
) => {
if (amount == null || currency == null) return 'Free'
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase() || 'USD',
minimumFractionDigits: 2,
})
return formatter.format(amount / 100)
}
const getBillingInterval = (interval: string | null | undefined) => {
if (!interval) return ''
return interval.toLowerCase() === 'month' ? 'monthly' : 'yearly'
}
if (isLoadingSubscription)
return (
<div className='flex justify-center items-center h-full min-h-[200px]'>
<Spinner size='sm' />
</div>
)
if (subscriptionError)
return (
<p className='text-sm text-destructive'>
Failed to load subscription information
</p>
)
return (
<div className='bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 border rounded-lg shadow p-5'>
<div className='flex items-center justify-between mb-5'>
<div>
<h3 className='text-xl font-bold text-gray-900 dark:text-white'>
{currentSubscription?.plan?.name || 'Free Plan'}
</h3>
<div className='flex items-center gap-2'>
<p className='text-sm text-gray-500 dark:text-gray-400'>
Current subscription
</p>
{currentSubscription?.amount > 0 && (
<Badge variant='outline' className='text-xs font-medium'>
{formatPrice(
currentSubscription?.amount,
currentSubscription?.currency
)}
{currentSubscription?.recurringInterval && (
<span className='ml-1'>
/{' '}
{getBillingInterval(currentSubscription?.recurringInterval)}
</span>
)}
</Badge>
)}
</div>
</div>
<div
className={`flex items-center px-2 py-0.5 rounded-full ${
currentSubscription?.status === 'active'
? 'bg-green-50 dark:bg-green-900/30'
: currentSubscription?.status === 'past_due'
? 'bg-amber-50 dark:bg-amber-900/30'
: 'bg-gray-50 dark:bg-gray-800/50'
}`}
>
<Check
className={`h-3 w-3 mr-1 ${
currentSubscription?.status === 'active'
? 'text-green-600 dark:text-green-400'
: currentSubscription?.status === 'past_due'
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-600 dark:text-gray-400'
}`}
/>
<span
className={`text-xs font-medium ${
currentSubscription?.status === 'active'
? 'text-green-600 dark:text-green-400'
: currentSubscription?.status === 'past_due'
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-600 dark:text-gray-400'
}`}
>
{currentSubscription?.status
? currentSubscription.status
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
: 'Active'}
</span>
</div>
</div>
<div className='grid grid-cols-2 gap-3 mb-5'>
<div className='flex items-center space-x-2 bg-white dark:bg-gray-800 p-3 rounded-md shadow-sm'>
<Calendar className='h-4 w-4 text-brand-600 dark:text-brand-400' />
<div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Start Date
</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.subscriptionStartDate
? new Date(
currentSubscription?.subscriptionStartDate
).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
: 'N/A'}
</p>
</div>
</div>
<div className='flex items-center space-x-2 bg-white dark:bg-gray-800 p-3 rounded-md shadow-sm'>
<Calendar className='h-4 w-4 text-brand-600 dark:text-brand-400' />
<div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Next Payment
</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.currentPeriodEnd
? new Date(
currentSubscription?.currentPeriodEnd
).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
: 'N/A'}
</p>
</div>
</div>
</div>
<div className='bg-white dark:bg-gray-800 p-4 rounded-md shadow-sm mb-5'>
<p className='text-xs text-gray-500 dark:text-gray-400 mb-3 font-medium'>
Usage Limits
</p>
<div className='grid grid-cols-3 gap-3'>
<div className='bg-gray-50 dark:bg-gray-700/50 p-2 rounded-md'>
<p className='text-xs text-gray-500 dark:text-gray-400'>Daily</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.plan?.dailyLimit === -1
? 'Unlimited'
: currentSubscription?.plan?.dailyLimit || '0'}
{currentSubscription?.plan?.dailyLimit === -1 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center'>
<Info className='h-4 w-4 text-gray-500 ml-1 cursor-pointer' />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Unlimited (within monthly limit)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</p>
</div>
<div className='bg-gray-50 dark:bg-gray-700/50 p-2 rounded-md'>
<p className='text-xs text-gray-500 dark:text-gray-400'>Monthly</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.plan?.monthlyLimit === -1
? 'Unlimited'
: currentSubscription?.plan?.monthlyLimit?.toLocaleString() ||
'0'}
{currentSubscription?.plan?.monthlyLimit === -1 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center'>
<Info className='h-4 w-4 text-gray-500 ml-1 cursor-pointer' />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Unlimited (within fair usage)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</p>
</div>
<div className='bg-gray-50 dark:bg-gray-700/50 p-2 rounded-md'>
<p className='text-xs text-gray-500 dark:text-gray-400'>Bulk</p>
<p className='text-sm font-medium text-gray-900 dark:text-white'>
{currentSubscription?.plan?.bulkSendLimit === -1
? 'Unlimited'
: currentSubscription?.plan?.bulkSendLimit || '0'}
{currentSubscription?.plan?.bulkSendLimit === -1 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className='inline-flex items-center'>
<Info className='h-4 w-4 text-gray-500 ml-1 cursor-pointer' />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Unlimited (within monthly limit)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</p>
</div>
</div>
</div>
<div className='flex justify-end'>
{!currentSubscription?.plan?.name ||
currentSubscription?.plan?.name?.toLowerCase() === 'free' ? (
<Link
href='/checkout/pro'
className='text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 px-4 py-2 rounded-md transition-colors'
>
Upgrade to Pro
</Link>
) : (
<Link
href='https://polar.sh/textbee/portal/'
className='text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 px-4 py-2 rounded-md transition-colors'
>
Manage Subscription
</Link>
)}
</div>
</div>
)
}
@@ -0,0 +1,209 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { AlertTriangle, Check, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from '@/hooks/use-toast'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useRouter } from 'next/navigation'
const SupportFormSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }),
email: z.string().email({ message: 'Invalid email address' }),
phone: z.string().optional(),
category: z.enum(['general', 'technical', 'billing-and-payments', 'other'], {
message: 'Support category is required',
}),
message: z.string().min(1, { message: 'Message is required' }),
})
export default function SupportForm() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitSuccessful, setIsSubmitSuccessful] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const router = useRouter()
const form = useForm({
resolver: zodResolver(SupportFormSchema),
defaultValues: {
name: '',
email: '',
phone: '',
category: 'general',
message: '',
},
})
const onSubmit = async (data: any) => {
setIsSubmitting(true)
setErrorMessage(null)
try {
// Use the existing httpBrowserClient to call the NestJS endpoint
const response = await httpBrowserClient.post(
ApiEndpoints.support.customerSupport(),
data
)
setIsSubmitSuccessful(true)
toast({
title: 'Support request submitted',
description: response.data.message || 'We will get back to you soon.',
})
} catch (error) {
console.error('Error submitting support request:', error)
setErrorMessage(
'Error submitting support request. Please try again later.'
)
toast({
title: 'Error submitting support request',
description: 'Please try again later',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<FormField
control={form.control}
name='category'
disabled={isSubmitting}
render={({ field }) => (
<FormItem>
<FormLabel>Support Category</FormLabel>
<Select
onValueChange={field.onChange}
disabled={isSubmitting}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select support category' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='general'>General Inquiry</SelectItem>
<SelectItem value='technical'>Technical Support</SelectItem>
<SelectItem value='billing-and-payments'>
Billing and Payments
</SelectItem>
<SelectItem value='other'>Other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='name'
disabled={isSubmitting}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder='Your name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
disabled={isSubmitting}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder='your@email.com' type='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='phone'
disabled={isSubmitting}
render={({ field }) => (
<FormItem>
<FormLabel>Phone (Optional)</FormLabel>
<FormControl>
<Input placeholder='+1234567890' type='tel' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='message'
disabled={isSubmitting}
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl>
<Textarea
placeholder='How can we help you?'
className='min-h-[100px]'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isSubmitSuccessful && (
<div className='flex items-center gap-2 text-green-500'>
<Check className='h-4 w-4' /> We have received your message, we will
get back to you soon.
</div>
)}
{errorMessage && (
<div className='flex items-center gap-2 text-red-500'>
<AlertTriangle className='h-4 w-4' /> {errorMessage}
</div>
)}
<Button type='submit' disabled={isSubmitting} className='w-full'>
{isSubmitting ? (
<>
<Loader2 className='h-4 w-4 animate-spin mr-2' /> Submitting...
</>
) : (
'Submit'
)}
</Button>
</form>
</Form>
)
}

Some files were not shown because too many files have changed in this diff Show More