Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fe0f48ef9 | ||
|
|
f520f378d0 | ||
|
|
b0ce11e392 | ||
|
|
7012b04439 | ||
|
|
375cf63adb | ||
|
|
6886dd2f1a | ||
|
|
5acec3f04a | ||
|
|
6861a4d36b | ||
|
|
54fb586d9b | ||
|
|
60bcd691c8 | ||
|
|
dd2c01678f | ||
|
|
2633fc80cc | ||
|
|
da9efc036b | ||
|
|
7d2b8093ae | ||
|
|
15a67446ea | ||
|
|
50e7b7536d | ||
|
|
d868b43d52 | ||
|
|
c4dc4bcb88 | ||
|
|
c7b206be91 | ||
|
|
f88c94fbbd | ||
|
|
59525cb126 | ||
|
|
383cf213d8 | ||
|
|
9e15483752 | ||
|
|
ea49398e4d | ||
|
|
cde19e01e0 | ||
|
|
ef59f86071 | ||
|
|
1e602b85e1 | ||
|
|
2b110b6fc5 | ||
|
|
89ed36849b | ||
|
|
0ac3ccb144 | ||
|
|
9b9d92592c | ||
|
|
7fc7fbf86f | ||
|
|
cbffeca689 | ||
|
|
dfd6c5c3d2 | ||
|
|
c1af584093 | ||
|
|
db3ac97af8 | ||
|
|
25ca1f7be0 | ||
|
|
2be8b3e8c7 | ||
|
|
7694f3f60a | ||
|
|
52e88e7e36 | ||
|
|
64859b38ad | ||
|
|
601f33d95e | ||
|
|
f355065bfb | ||
|
|
0bd1818678 | ||
|
|
cdf3b7bb51 | ||
|
|
61a666da35 | ||
|
|
fa81292bd8 |
@@ -62,7 +62,6 @@ jobs:
|
||||
run: |
|
||||
cd web
|
||||
pnpm install
|
||||
pnpm run prisma:generate
|
||||
pnpm run build
|
||||
|
||||
build-and-test-android:
|
||||
|
||||
@@ -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/)
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
|
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>
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
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,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>
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
export enum WebhookEvent {
|
||||
MESSAGE_RECEIVED = 'MESSAGE_RECEIVED',
|
||||
SMS_STATUS_UPDATED = 'SMS_STATUS_UPDATED',
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||