diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1bc158d13..90cee64b8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,7 +24,11 @@ const App = (): JSX.Element => { - {(window.NativeRobosats === undefined && window.RobosatsClient === undefined )? : } + {window.NativeRobosats === undefined && window.RobosatsClient === undefined ? ( + + ) : ( + + )}
diff --git a/frontend/src/basic/Main.tsx b/frontend/src/basic/Main.tsx index 328c0b66c..d78533d31 100644 --- a/frontend/src/basic/Main.tsx +++ b/frontend/src/basic/Main.tsx @@ -1,16 +1,17 @@ import React, { useContext } from 'react'; -import { MemoryRouter,HashRouter ,BrowserRouter, Routes, Route } from 'react-router-dom'; -import { Box, Slide, Typography, styled } from '@mui/material'; +import { MemoryRouter, HashRouter, BrowserRouter } from 'react-router-dom'; +import { Box, Typography, styled } from '@mui/material'; import { type UseAppStoreType, AppContext, closeAll } from '../contexts/AppContext'; -import { RobotPage, MakerPage, BookPage, OrderPage, SettingsPage, NavBar, MainDialogs } from './'; +import { NavBar, MainDialogs } from './'; import RobotAvatar from '../components/RobotAvatar'; import Notifications from '../components/Notifications'; import { useTranslation } from 'react-i18next'; import { GarageContext, type UseGarageStoreType } from '../contexts/GarageContext'; +import Routes from './Routes'; -function getRouter() { +const getRouter = (): any => { if (window.NativeRobosats === undefined && window.RobosatsClient === undefined) { return BrowserRouter; } else if (window.RobosatsClient === 'desktop-app') { @@ -18,7 +19,7 @@ function getRouter() { } else { return MemoryRouter; } -} +}; const Router = getRouter(); const TestnetTypography = styled(Typography)({ @@ -38,7 +39,7 @@ const MainBox = styled(Box)((props) => ({ const Main: React.FC = () => { const { t } = useTranslation(); - const { settings, page, slideDirection, setOpen, windowSize, navbarHeight } = + const { settings, page, setOpen, windowSize, navbarHeight } = useContext(AppContext); const { garage } = useContext(GarageContext); @@ -62,88 +63,7 @@ const Main: React.FC = () => { )} - - - {['/robot/:token?', '/', ''].map((path, index) => { - return ( - -
- -
- - } - key={index} - /> - ); - })} - - -
- -
- - } - /> - - -
- -
- - } - /> - - -
- -
- - } - /> - - -
- -
- - } - /> -
+
diff --git a/frontend/src/basic/Routes.tsx b/frontend/src/basic/Routes.tsx new file mode 100644 index 000000000..909a7e459 --- /dev/null +++ b/frontend/src/basic/Routes.tsx @@ -0,0 +1,114 @@ +import React, { useContext, useEffect } from 'react'; +import { Routes as DomRoutes, Route, useNavigate } from 'react-router-dom'; +import { Slide } from '@mui/material'; +import { type UseAppStoreType, AppContext } from '../contexts/AppContext'; + +import { RobotPage, MakerPage, BookPage, OrderPage, SettingsPage } from '.'; +import { GarageContext, type UseGarageStoreType } from '../contexts/GarageContext'; + +const Routes: React.FC = () => { + const navigate = useNavigate(); + const { garage } = useContext(GarageContext); + const { page, slideDirection } = useContext(AppContext); + + useEffect(() => { + window.addEventListener('navigateToPage', (event) => { + console.log('navigateToPage', JSON.stringify(event)); + const orderId: string = event?.detail?.order_id; + const coordinator: string = event?.detail?.coordinator; + if (orderId && coordinator) { + const slot = garage.getSlotByOrder(coordinator, orderId); + if (slot?.token) { + garage.setCurrentSlot(slot?.token); + navigate(`/order/${coordinator}/${orderId}`); + } + } + }); + }, []); + + return ( + + {['/robot/:token?', '/', ''].map((path, index) => { + return ( + +
+ +
+ + } + key={index} + /> + ); + })} + + +
+ +
+ + } + /> + + +
+ +
+ + } + /> + + +
+ +
+ + } + /> + + +
+ +
+ + } + /> +
+ ); +}; + +export default Routes; diff --git a/frontend/src/components/OrderDetails/index.tsx b/frontend/src/components/OrderDetails/index.tsx index a368df363..1f4437ecd 100644 --- a/frontend/src/components/OrderDetails/index.tsx +++ b/frontend/src/components/OrderDetails/index.tsx @@ -174,8 +174,8 @@ const OrderDetails = ({ const isBuyer = (order.type === 0 && order.is_maker) || (order.type === 1 && !order.is_maker); const tradeFee = order.is_maker - ? (coordinator.info?.maker_fee ?? 0) - : (coordinator.info?.taker_fee ?? 0); + ? coordinator.info?.maker_fee ?? 0 + : coordinator.info?.taker_fee ?? 0; const defaultRoutingBudget = 0.001; const btc_now = order.satoshis_now / 100000000; const rate = Number(order.max_amount ?? order.amount) / btc_now; diff --git a/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx index bccc035eb..43de3a8f4 100644 --- a/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx +++ b/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx @@ -39,7 +39,7 @@ const audioPath = window.NativeRobosats === undefined ? '/static/assets/sounds' : 'file:///android_asset/Web.bundle/assets/sounds'; - + const EncryptedTurtleChat: React.FC = ({ order, userNick, diff --git a/frontend/src/contexts/AppContext.tsx b/frontend/src/contexts/AppContext.tsx index bd543bf96..26bc3c367 100644 --- a/frontend/src/contexts/AppContext.tsx +++ b/frontend/src/contexts/AppContext.tsx @@ -76,20 +76,13 @@ const makeTheme = function (settings: Settings): Theme { }; const getHostUrl = (network = 'mainnet'): string => { - let host = ''; - let protocol = ''; - if(isDesktopRoboSats){ - host = defaultFederation.exp[network].onion; - protocol = 'http:'; - } - else if (window.NativeRobosats === undefined) { + let host = defaultFederation.exp[network].onion; + let protocol = 'http:'; + if (window.NativeRobosats === undefined) { host = getHost(); protocol = location.protocol; - } else { - host = defaultFederation.exp[network].onion; - protocol = 'http:'; } - const hostUrl = `${host}`; + const hostUrl = `${protocol}//${host}`; return hostUrl; }; diff --git a/frontend/src/models/Federation.model.ts b/frontend/src/models/Federation.model.ts index d39b25ee6..295fc90f2 100644 --- a/frontend/src/models/Federation.model.ts +++ b/frontend/src/models/Federation.model.ts @@ -8,6 +8,7 @@ import { defaultExchange, } from '.'; import defaultFederation from '../../static/federation.json'; +import { systemClient } from '../services/System'; import { getHost } from '../utils'; import { coordinatorDefaultValues } from './Coordinator.model'; import { updateExchangeInfo } from './Exchange.model'; @@ -105,9 +106,12 @@ export class Federation { }; updateUrl = async (origin: Origin, settings: Settings, hostUrl: string): Promise => { + const federationUrls = {}; for (const coor of Object.values(this.coordinators)) { coor.updateUrl(origin, settings, hostUrl); + federationUrls[coor.shortAlias] = coor.url; } + systemClient.setCookie('federation', JSON.stringify(federationUrls)); }; update = async (): Promise => { diff --git a/frontend/src/models/Garage.model.ts b/frontend/src/models/Garage.model.ts index b1f35f6c7..07645be99 100644 --- a/frontend/src/models/Garage.model.ts +++ b/frontend/src/models/Garage.model.ts @@ -83,7 +83,7 @@ class Garage { // Slots getSlot: (token?: string) => Slot | null = (token) => { const currentToken = token ?? this.currentSlot; - return currentToken ? (this.slots[currentToken] ?? null) : null; + return currentToken ? this.slots[currentToken] ?? null : null; }; deleteSlot: (token?: string) => void = (token) => { @@ -104,6 +104,7 @@ class Garage { const slot = this.getSlot(token); if (attributes) { if (attributes.copiedToken !== undefined) slot?.setCopiedToken(attributes.copiedToken); + this.save(); this.triggerHook('onRobotUpdate'); } return slot; @@ -111,9 +112,22 @@ class Garage { setCurrentSlot: (currentSlot: string) => void = (currentSlot) => { this.currentSlot = currentSlot; + this.save(); this.triggerHook('onRobotUpdate'); }; + getSlotByOrder: (coordinator: string, orderID: number) => Slot | null = ( + coordinator, + orderID, + ) => { + return ( + Object.values(this.slots).find((slot) => { + const robot = slot.getRobot(coordinator); + return slot.activeShortAlias === coordinator && robot?.activeOrderId === orderID; + }) ?? null + ); + }; + // Robots createRobot: (token: string, shortAliases: string[], attributes: Record) => void = ( token, @@ -174,6 +188,7 @@ class Garage { Object.values(this.slots).forEach((slot) => { slot.syncCoordinator(coordinator, this); }); + this.save(); }; } diff --git a/frontend/src/services/Native/index.d.ts b/frontend/src/services/Native/index.d.ts index 4d7a17cbb..31208dfd8 100644 --- a/frontend/src/services/Native/index.d.ts +++ b/frontend/src/services/Native/index.d.ts @@ -26,7 +26,13 @@ export interface NativeWebViewMessageHttp { export interface NativeWebViewMessageSystem { id?: number; category: 'system'; - type: 'init' | 'torStatus' | 'copyToClipboardString' | 'setCookie' | 'deleteCookie'; + type: + | 'init' + | 'torStatus' + | 'copyToClipboardString' + | 'setCookie' + | 'deleteCookie' + | 'navigateToPage'; key?: string; detail?: string; } diff --git a/frontend/src/services/Native/index.ts b/frontend/src/services/Native/index.ts index ad2d49a34..5e8941a15 100644 --- a/frontend/src/services/Native/index.ts +++ b/frontend/src/services/Native/index.ts @@ -45,6 +45,8 @@ class NativeRobosats { if (message.key !== undefined) { this.cookies[message.key] = String(message.detail); } + } else if (message.type === 'navigateToPage') { + window.dispatchEvent(new CustomEvent('navigateToPage', { detail: message?.detail })); } }; diff --git a/frontend/static/locales/ca.json b/frontend/static/locales/ca.json index f9a394f61..50c080c7e 100644 --- a/frontend/static/locales/ca.json +++ b/frontend/static/locales/ca.json @@ -250,7 +250,7 @@ "Tell us about a new feature or a bug": "Proposa funcionalitats o notifica errors", "We are abandoning Telegram! Our old TG groups": "Estem deixant Telegram! Els nostres grups antics de TG", "X Official Account": "Compte oficial a X", - "#23": "Phrases in components/Dialogs/Coordinator.tsx", + "#24": "Phrases in components/Dialogs/Coordinator.tsx", "...Opening on Nostr gateway. Pubkey copied!": "...Obrint la passarel·la Nostr. Clau pública copiada!", "24h contracted volume": "Volum contractat en 24h", "24h non-KYC bitcoin premium": "Prima de bitcoin sense KYC en 24h", @@ -475,7 +475,7 @@ "You send via Lightning {{amount}} Sats (Approx)": "Tu envies via Lightning {{amount}} Sats (Approx)", "You send via {{method}} {{amount}}": "Envies via {{method}} {{amount}}", "{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Prima: {{premium}}%", - "#42": "Phrases in components/RobotInfo/index.tsx", + "#43": "Phrases in components/RobotInfo/index.tsx", "Active order!": "Active order!", "Claim": "Retirar", "Claim Sats!": "Reclamar Sats!", diff --git a/mobile/App.tsx b/mobile/App.tsx index e29775734..383a77345 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -7,6 +7,8 @@ import EncryptedStorage from 'react-native-encrypted-storage'; import { name as app_name, version as app_version } from './package.json'; import TorModule from './native/TorModule'; import RoboIdentitiesModule from './native/RoboIdentitiesModule'; +import NotificationsModule from './native/NotificationsModule'; +import SystemModule from './native/SystemModule'; const backgroundColors = { light: 'white', @@ -23,6 +25,14 @@ const App = () => { useEffect(() => { TorModule.start(); + DeviceEventEmitter.addListener('navigateToPage', (payload) => { + window.navigateToPage = payload; + injectMessage({ + category: 'system', + type: 'navigateToPage', + detail: payload, + }); + }); DeviceEventEmitter.addListener('TorStatus', (payload) => { if (payload.torStatus === 'OFF') TorModule.restart(); injectMessage({ @@ -54,6 +64,17 @@ const App = () => { ); }; + const onLoadEnd = () => { + if (window.navigateToPage) { + injectMessage({ + category: 'system', + type: 'navigateToPage', + detail: window.navigateToPage, + }); + window.navigateToPage = undefined; + } + }; + const init = (responseId: string) => { const loadCookie = async (key: string) => { return await EncryptedStorage.getItem(key).then((value) => { @@ -62,6 +83,7 @@ const App = () => { webViewRef.current?.injectJavaScript( `(function() {window.NativeRobosats?.loadCookie(${json});})();`, ); + return value; } }); }; @@ -71,8 +93,13 @@ const App = () => { loadCookie('settings_mode'); loadCookie('settings_light_qr'); loadCookie('settings_network'); - loadCookie('settings_use_proxy'); - loadCookie('garage_slots').then(() => injectMessageResolve(responseId)); + loadCookie('settings_use_proxy').then((useProxy) => { + SystemModule.useProxy(useProxy ?? 'true'); + }); + loadCookie('garage_slots').then((slots) => { + NotificationsModule.monitorOrders(slots ?? '{}'); + injectMessageResolve(responseId); + }); }; const onCatch = (dataId: string, event: any) => { @@ -128,6 +155,13 @@ const App = () => { Clipboard.setString(data.detail); } else if (data.type === 'setCookie') { setCookie(data.key, data.detail); + if (data.key === 'federation') { + SystemModule.setFederation(data.detail ?? '{}'); + } else if (data.key === 'garage_slots') { + NotificationsModule.monitorOrders(data.detail ?? '{}'); + } else if (data.key === 'settings_use_proxy') { + SystemModule.useProxy(data.detail ?? 'true'); + } } else if (data.type === 'deleteCookie') { EncryptedStorage.removeItem(data.key); } @@ -185,6 +219,7 @@ const App = () => { allowsLinkPreview={false} renderLoading={() => } onError={(syntheticEvent) => {syntheticEvent.type}} + onLoadEnd={() => setTimeout(onLoadEnd, 3000)} /> ); diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index f76cd6995..8ef828f52 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ + + + + 0) { + navigateToPage(coordinator, order_id); + } + } + } + + @Override + public void onNewIntent(Intent intent) { + super.onNewIntent(intent); + if (intent != null) { + String coordinator = intent.getStringExtra("coordinator"); + int order_id = intent.getIntExtra("order_id", 0); + if (order_id > 0) { + navigateToPage(coordinator, order_id); + } + } + } /** * Returns the name of the main component registered from JavaScript. This is used to schedule @@ -25,6 +70,31 @@ protected ReactActivityDelegate createReactActivityDelegate() { return new MainActivityDelegate(this, getMainComponentName()); } + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == REQUEST_CODE_POST_NOTIFICATIONS) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Intent serviceIntent = new Intent(getApplicationContext(), NotificationsService.class); + getApplicationContext().startService(serviceIntent); + } else { + // Permission denied, handle accordingly + // Maybe show a message to the user explaining why the permission is necessary + } + } + } + + private void navigateToPage(String coordinator, Integer order_id) { + ReactContext reactContext = getReactInstanceManager().getCurrentReactContext(); + if (reactContext != null) { + WritableMap payload = Arguments.createMap(); + payload.putString("coordinator", coordinator); + payload.putInt("order_id", order_id); + reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit("navigateToPage", payload); + } + } + public static class MainActivityDelegate extends ReactActivityDelegate { public MainActivityDelegate(ReactActivity activity, String mainComponentName) { super(activity, mainComponentName); diff --git a/mobile/android/app/src/main/java/com/robosats/NotificationsService.java b/mobile/android/app/src/main/java/com/robosats/NotificationsService.java new file mode 100644 index 000000000..59046d182 --- /dev/null +++ b/mobile/android/app/src/main/java/com/robosats/NotificationsService.java @@ -0,0 +1,301 @@ +package com.robosats; + +import android.app.Application; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.IBinder; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.robosats.tor.TorKmp; +import com.robosats.tor.TorKmpManager; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Iterator; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import kotlin.UninitializedPropertyAccessException; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class NotificationsService extends Service { + private Handler handler; + private Runnable periodicTask; + private static final String CHANNEL_ID = "robosats_notifications"; + private static final int NOTIFICATION_ID = 76453; + private static final long INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + private static final String PREFS_NAME_NOTIFICATION = "Notifications"; + private static final String PREFS_NAME_SYSTEM = "System"; + private static final String KEY_DATA_SLOTS = "Slots"; + private static final String KEY_DATA_PROXY = "UsePoxy"; + private static final String KEY_DATA_FEDERATION = "Federation"; + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + createNotificationChannel(); + startForeground(NOTIFICATION_ID, buildServiceNotification()); + + handler = new Handler(); + periodicTask = new Runnable() { + @Override + public void run() { + Log.d("NotificationsService", "Running periodic task"); + executeBackgroundTask(); + handler.postDelayed(periodicTask, INTERVAL_MS); + } + }; + + Log.d("NotificationsService", "Squeduling periodic task"); + handler.postDelayed(periodicTask, 5000); + return START_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (handler != null && periodicTask != null) { + handler.removeCallbacks(periodicTask); + } + + stopForeground(true); + } + + private void createNotificationChannel() { + NotificationManager manager = getSystemService(NotificationManager.class); + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "Robosats", + NotificationManager.IMPORTANCE_DEFAULT + ); + manager.createNotificationChannel(channel); + } + + private void executeBackgroundTask() { + ExecutorService executor = Executors.newSingleThreadExecutor(); + executor.submit(this::checkNotifications); + executor.shutdown(); + } + + private Notification buildServiceNotification() { + Intent intent = new Intent(this, MainActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Tor Notifications") + .setContentText("Running in the background every 5 minutes to check for notifications.") + .setSmallIcon(R.mipmap.ic_icon) + .setTicker("Robosats") + .setPriority(NotificationCompat.PRIORITY_MIN) + .setOngoing(true) + .setAutoCancel(false) + .setContentIntent(pendingIntent); + + return builder.build(); + } + + public void checkNotifications() { + Log.d("NotificationsService", "checkNotifications"); + SharedPreferences sharedPreferences = + getApplicationContext() + .getSharedPreferences(PREFS_NAME_NOTIFICATION, ReactApplicationContext.MODE_PRIVATE); + String slotsJson = sharedPreferences.getString(KEY_DATA_SLOTS, null); + + try { + assert slotsJson != null; + JSONObject slots = new JSONObject(slotsJson); + Iterator it = slots.keys(); + + while (it.hasNext()) { + String robotToken = it.next(); + JSONObject slot = (JSONObject) slots.get(robotToken); + JSONObject robots = slot.getJSONObject("robots"); + JSONObject coordinatorRobot; + String shortAlias = slot.getString("activeShortAlias"); + coordinatorRobot = robots.getJSONObject(shortAlias); + fetchNotifications(coordinatorRobot, shortAlias); + } + } catch (JSONException | InterruptedException e) { + Log.d("NotificationsService", "Error reading garage: " + e); + } + } + + private void fetchNotifications(JSONObject robot, String coordinator) throws JSONException, InterruptedException { + String token = robot.getString("tokenSHA256"); + SharedPreferences sharedPreferences = + getApplicationContext() + .getSharedPreferences(PREFS_NAME_SYSTEM, ReactApplicationContext.MODE_PRIVATE); + boolean useProxy = Objects.equals(sharedPreferences.getString(KEY_DATA_PROXY, null), "true"); + JSONObject federation = new JSONObject(sharedPreferences.getString(KEY_DATA_FEDERATION, "{}")); + long unix_time_millis = sharedPreferences.getLong(token, 0); + String url = federation.getString(coordinator) + "/api/notifications"; + if (unix_time_millis > 0) { + String last_created_at = String + .valueOf(LocalDateTime.ofInstant(Instant.ofEpochMilli(unix_time_millis), ZoneId.of("UTC"))); + url += "?created_at=" + last_created_at; + } + + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) // Set connection timeout + .readTimeout(30, TimeUnit.SECONDS); // Set read timeout + + if (useProxy) { + TorKmp tor = this.getTorKmp(); + builder.proxy(tor.getProxy()); + } + + OkHttpClient client = builder.build(); + Request.Builder requestBuilder = new Request.Builder().url(url); + + requestBuilder + .addHeader("Authorization", "Token " + token); + + requestBuilder.get(); + Request request = requestBuilder.build(); + client.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + displayErrorNotification(); + Log.d("NotificationsService", "Error fetching coordinator: " + e.toString()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + String body = response.body() != null ? response.body().string() : "{}"; + JSONObject headersJson = new JSONObject(); + response.headers().names().forEach(name -> { + try { + headersJson.put(name, response.header(name)); + } catch (JSONException e) { + throw new RuntimeException(e); + } + }); + try { + JSONArray results = new JSONArray(body); + for (int i=0; i < results.length(); i++) { + JSONObject notification = results.getJSONObject(i); + Integer order_id = notification.getInt("order_id"); + String title = notification.getString("title"); + + if (title.isEmpty()) { + continue; + } + + displayOrderNotification(order_id, title, coordinator); + + long milliseconds; + try { + String created_at = notification.getString("created_at"); + LocalDateTime datetime = LocalDateTime.parse(created_at, DateTimeFormatter.ISO_DATE_TIME); + milliseconds = datetime.toInstant(ZoneOffset.UTC).toEpochMilli() + 1000; + } catch (JSONException e) { + milliseconds = System.currentTimeMillis(); + } + + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putLong(token, milliseconds); + editor.apply(); + break; + } + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + }); + + } + + private void displayOrderNotification(Integer order_id, String message, String coordinator) { + NotificationManager notificationManager = (NotificationManager) + getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE); + + Intent intent = new Intent(this.getApplicationContext(), MainActivity.class); + intent.putExtra("coordinator", coordinator); + intent.putExtra("order_id", order_id); + PendingIntent pendingIntent = PendingIntent.getActivity(this.getApplicationContext(), 0, + intent, PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(getApplicationContext(), CHANNEL_ID) + .setContentTitle("Order #" + order_id) + .setContentText(message) + .setSmallIcon(R.mipmap.ic_icon) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .setAutoCancel(true); + + notificationManager.notify(order_id, builder.build()); + } + + private void displayErrorNotification() { + NotificationManager notificationManager = (NotificationManager) + getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(getApplicationContext(), CHANNEL_ID) + .setContentTitle("Connection Error") + .setContentText("There was an error while connecting to the Tor network.") + .setSmallIcon(R.mipmap.ic_icon) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true); + + notificationManager.notify(0, builder.build()); + } + + private TorKmp getTorKmp() throws InterruptedException { + TorKmp torKmp; + try { + torKmp = TorKmpManager.INSTANCE.getTorKmpObject(); + } catch (UninitializedPropertyAccessException e) { + torKmp = new TorKmp((Application) this.getApplicationContext()); + } + + int retires = 0; + while (!torKmp.isConnected() && retires < 15) { + if (!torKmp.isStarting()) { + torKmp.getTorOperationManager().startQuietly(); + } + Thread.sleep(2000); + retires += 1; + } + + if (!torKmp.isConnected()) { + displayErrorNotification(); + } + + return torKmp; + } +} diff --git a/mobile/android/app/src/main/java/com/robosats/RobosatsPackage.java b/mobile/android/app/src/main/java/com/robosats/RobosatsPackage.java index 2eca00805..d3f5ca7e8 100644 --- a/mobile/android/app/src/main/java/com/robosats/RobosatsPackage.java +++ b/mobile/android/app/src/main/java/com/robosats/RobosatsPackage.java @@ -4,7 +4,9 @@ import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.uimanager.ViewManager; +import com.robosats.modules.NotificationsModule; import com.robosats.modules.RoboIdentitiesModule; +import com.robosats.modules.SystemModule; import com.robosats.modules.TorModule; import java.util.ArrayList; @@ -22,7 +24,9 @@ public List createNativeModules( ReactApplicationContext reactContext) { List modules = new ArrayList<>(); + modules.add(new SystemModule(reactContext)); modules.add(new TorModule(reactContext)); + modules.add(new NotificationsModule(reactContext)); modules.add(new RoboIdentitiesModule(reactContext)); return modules; diff --git a/mobile/android/app/src/main/java/com/robosats/modules/NotificationsModule.java b/mobile/android/app/src/main/java/com/robosats/modules/NotificationsModule.java new file mode 100644 index 000000000..c0ac9163a --- /dev/null +++ b/mobile/android/app/src/main/java/com/robosats/modules/NotificationsModule.java @@ -0,0 +1,30 @@ +package com.robosats.modules; + +import android.content.SharedPreferences; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +public class NotificationsModule extends ReactContextBaseJavaModule { + public NotificationsModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return "NotificationsModule"; + } + + @ReactMethod + public void monitorOrders(String slots_json) { + String PREFS_NAME = "Notifications"; + String KEY_DATA = "Slots"; + SharedPreferences sharedPreferences = getReactApplicationContext().getSharedPreferences(PREFS_NAME, ReactApplicationContext.MODE_PRIVATE); + + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(KEY_DATA, slots_json); + + editor.apply(); + } +} diff --git a/mobile/android/app/src/main/java/com/robosats/modules/SystemModule.java b/mobile/android/app/src/main/java/com/robosats/modules/SystemModule.java new file mode 100644 index 000000000..f62063686 --- /dev/null +++ b/mobile/android/app/src/main/java/com/robosats/modules/SystemModule.java @@ -0,0 +1,41 @@ +package com.robosats.modules; + +import android.content.SharedPreferences; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +public class SystemModule extends ReactContextBaseJavaModule { + public SystemModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return "SystemModule"; + } + + @ReactMethod + public void useProxy(String use_proxy) { + String PREFS_NAME = "System"; + String KEY_DATA = "UsePoxy"; + SharedPreferences sharedPreferences = getReactApplicationContext().getSharedPreferences(PREFS_NAME, ReactApplicationContext.MODE_PRIVATE); + + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(KEY_DATA, use_proxy); + + editor.apply(); + } + @ReactMethod + public void setFederation(String use_proxy) { + String PREFS_NAME = "System"; + String KEY_DATA = "Federation"; + SharedPreferences sharedPreferences = getReactApplicationContext().getSharedPreferences(PREFS_NAME, ReactApplicationContext.MODE_PRIVATE); + + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(KEY_DATA, use_proxy); + + editor.apply(); + } +} diff --git a/mobile/android/app/src/main/java/com/robosats/modules/TorModule.java b/mobile/android/app/src/main/java/com/robosats/modules/TorModule.java index 1266b152b..8e1455543 100644 --- a/mobile/android/app/src/main/java/com/robosats/modules/TorModule.java +++ b/mobile/android/app/src/main/java/com/robosats/modules/TorModule.java @@ -1,5 +1,6 @@ package com.robosats.modules; +import android.app.Application; import android.util.Log; import androidx.annotation.NonNull; @@ -11,6 +12,7 @@ import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.WritableMap; import com.facebook.react.modules.core.DeviceEventManagerModule; +import com.robosats.tor.TorKmp; import com.robosats.tor.TorKmpManager; import org.json.JSONException; @@ -20,6 +22,7 @@ import java.util.Objects; import java.util.concurrent.TimeUnit; +import kotlin.UninitializedPropertyAccessException; import okhttp3.Call; import okhttp3.Callback; import okhttp3.MediaType; @@ -30,10 +33,11 @@ public class TorModule extends ReactContextBaseJavaModule { - private TorKmpManager torKmpManager; private ReactApplicationContext context; public TorModule(ReactApplicationContext reactContext) { context = reactContext; + TorKmp torKmpManager = new TorKmp((Application) context.getApplicationContext()); + TorKmpManager.INSTANCE.updateTorKmpObject(torKmpManager); } @Override @@ -42,11 +46,12 @@ public String getName() { } @ReactMethod - public void sendRequest(String action, String url, String headers, String body, final Promise promise) throws JSONException { + public void sendRequest(String action, String url, String headers, String body, final Promise promise) throws JSONException, UninitializedPropertyAccessException { OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(60, TimeUnit.SECONDS) // Set connection timeout .readTimeout(30, TimeUnit.SECONDS) // Set read timeout - .proxy(torKmpManager.getProxy()).build(); + .proxy(TorKmpManager.INSTANCE.getTorKmpObject().getProxy()) + .build(); Request.Builder requestBuilder = new Request.Builder().url(url); @@ -89,8 +94,8 @@ public void onResponse(Call call, Response response) throws IOException { } @ReactMethod - public void getTorStatus() { - String torState = torKmpManager.getTorState().getState().name(); + public void getTorStatus() throws UninitializedPropertyAccessException { + String torState = TorKmpManager.INSTANCE.getTorKmpObject().getTorState().getState().name(); WritableMap payload = Arguments.createMap(); payload.putString("torStatus", torState); context @@ -99,8 +104,8 @@ public void getTorStatus() { } @ReactMethod - public void isConnected() { - String isConnected = String.valueOf(torKmpManager.isConnected()); + public void isConnected() throws UninitializedPropertyAccessException { + String isConnected = String.valueOf(TorKmpManager.INSTANCE.getTorKmpObject().isConnected()); WritableMap payload = Arguments.createMap(); payload.putString("isConnected", isConnected); context @@ -109,8 +114,8 @@ public void isConnected() { } @ReactMethod - public void isStarting() { - String isStarting = String.valueOf(torKmpManager.isStarting()); + public void isStarting() throws UninitializedPropertyAccessException { + String isStarting = String.valueOf(TorKmpManager.INSTANCE.getTorKmpObject().isStarting()); WritableMap payload = Arguments.createMap(); payload.putString("isStarting", isStarting); context @@ -119,8 +124,8 @@ public void isStarting() { } @ReactMethod - public void stop() { - torKmpManager.getTorOperationManager().stopQuietly(); + public void stop() throws UninitializedPropertyAccessException { + TorKmpManager.INSTANCE.getTorKmpObject().getTorOperationManager().stopQuietly(); WritableMap payload = Arguments.createMap(); context .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) @@ -128,9 +133,8 @@ public void stop() { } @ReactMethod - public void start() { - torKmpManager = new TorKmpManager(context.getCurrentActivity().getApplication()); - torKmpManager.getTorOperationManager().startQuietly(); + public void start() throws InterruptedException, UninitializedPropertyAccessException { + TorKmpManager.INSTANCE.getTorKmpObject().getTorOperationManager().startQuietly(); WritableMap payload = Arguments.createMap(); context .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) @@ -138,9 +142,10 @@ public void start() { } @ReactMethod - public void restart() { - torKmpManager = new TorKmpManager(context.getCurrentActivity().getApplication()); - torKmpManager.getTorOperationManager().restartQuietly(); + public void restart() throws UninitializedPropertyAccessException { + TorKmp torKmp = new TorKmp(context.getCurrentActivity().getApplication()); + TorKmpManager.INSTANCE.updateTorKmpObject(torKmp); + TorKmpManager.INSTANCE.getTorKmpObject().getTorOperationManager().restartQuietly(); WritableMap payload = Arguments.createMap(); context .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) @@ -148,8 +153,8 @@ public void restart() { } @ReactMethod - public void newIdentity() { - torKmpManager.newIdentity(context.getCurrentActivity().getApplication()); + public void newIdentity() throws UninitializedPropertyAccessException { + TorKmpManager.INSTANCE.getTorKmpObject().newIdentity(context.getCurrentActivity().getApplication()); WritableMap payload = Arguments.createMap(); context .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) diff --git a/mobile/android/app/src/main/java/com/robosats/tor/TorKmpManager.kt b/mobile/android/app/src/main/java/com/robosats/tor/TorKmpManager.kt index bed7fbc52..9accff0c4 100644 --- a/mobile/android/app/src/main/java/com/robosats/tor/TorKmpManager.kt +++ b/mobile/android/app/src/main/java/com/robosats/tor/TorKmpManager.kt @@ -27,8 +27,9 @@ import io.matthewnelson.kmp.tor.manager.R import kotlinx.coroutines.* import java.net.InetSocketAddress import java.net.Proxy +import java.util.concurrent.ExecutionException -class TorKmpManager(application : Application) { +class TorKmp(application : Application) { private val TAG = "TorListener" @@ -387,3 +388,16 @@ class TorKmpManager(application : Application) { } } } + +object TorKmpManager { + private lateinit var torKmp: TorKmp + + @Throws(UninitializedPropertyAccessException::class) + fun getTorKmpObject(): TorKmp { + return torKmp + } + + fun updateTorKmpObject(newKmpObject: TorKmp) { + torKmp = newKmpObject + } +} diff --git a/mobile/android/app/src/main/jniLibs/x86_64/librobohash.so b/mobile/android/app/src/main/jniLibs/x86_64/librobohash.so new file mode 100755 index 000000000..1e26077b4 Binary files /dev/null and b/mobile/android/app/src/main/jniLibs/x86_64/librobohash.so differ diff --git a/mobile/android/app/src/main/jniLibs/x86_64/librobonames.so b/mobile/android/app/src/main/jniLibs/x86_64/librobonames.so new file mode 100755 index 000000000..d6e1df920 Binary files /dev/null and b/mobile/android/app/src/main/jniLibs/x86_64/librobonames.so differ diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_icon.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_icon.png new file mode 100644 index 000000000..ab5e6f084 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_icon.png differ diff --git a/mobile/native/NotificationsModule.ts b/mobile/native/NotificationsModule.ts new file mode 100644 index 000000000..05751a6cc --- /dev/null +++ b/mobile/native/NotificationsModule.ts @@ -0,0 +1,10 @@ +import { NativeModules } from 'react-native'; +const { NotificationsModule } = NativeModules; + +interface NotificationsModuleInterface { + monitorOrders: (slotsJson: string) => void; + useProxy: (useProxy: string) => void; + setFederation: (federation: string) => void; +} + +export default NotificationsModule as NotificationsModuleInterface; diff --git a/mobile/native/RoboIdentitiesModule.ts b/mobile/native/RoboIdentitiesModule.ts index a5c3c83ea..e76c5336c 100644 --- a/mobile/native/RoboIdentitiesModule.ts +++ b/mobile/native/RoboIdentitiesModule.ts @@ -2,8 +2,8 @@ import { NativeModules } from 'react-native'; const { RoboIdentitiesModule } = NativeModules; interface RoboIdentitiesModuleInterface { - generateRoboname: (initialString: String) => Promise; - generateRobohash: (initialString: String) => Promise; + generateRoboname: (initialString: string) => Promise; + generateRobohash: (initialString: string) => Promise; } export default RoboIdentitiesModule as RoboIdentitiesModuleInterface; diff --git a/mobile/native/SystemModule.ts b/mobile/native/SystemModule.ts new file mode 100644 index 000000000..aba2a3927 --- /dev/null +++ b/mobile/native/SystemModule.ts @@ -0,0 +1,9 @@ +import { NativeModules } from 'react-native'; +const { SystemModule } = NativeModules; + +interface SystemModuleInterface { + useProxy: (useProxy: string) => void; + setFederation: (federation: string) => void; +} + +export default SystemModule as SystemModuleInterface; diff --git a/mobile/patch_modules/react-native-tor/android/build.gradle b/mobile/patch_modules/react-native-tor/android/build.gradle deleted file mode 100644 index f3d97dd92..000000000 --- a/mobile/patch_modules/react-native-tor/android/build.gradle +++ /dev/null @@ -1,132 +0,0 @@ -buildscript { - // Buildscript is evaluated before everything else so we can't use getExtOrDefault - def kotlin_version = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : project.properties['Tor_kotlinVersion'] - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } - - dependencies { - classpath 'com.android.tools.build:gradle:3.2.1' - // noinspection DifferentKotlinGradleVersion - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' - -def getExtOrDefault(name) { - return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['Tor_' + name] -} - -def getExtOrIntegerDefault(name) { - return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties['Tor_' + name]).toInteger() -} - -android { - compileSdkVersion getExtOrIntegerDefault('compileSdkVersion') - buildToolsVersion getExtOrDefault('buildToolsVersion') - defaultConfig { - minSdkVersion rootProject.ext.has('minSdkVersion') ? rootProject.ext.minSdkVersion : 16 - targetSdkVersion getExtOrIntegerDefault('targetSdkVersion') - versionCode 1 - versionName "1.0" - - } - - buildTypes { - release { - minifyEnabled false - } - } - lintOptions { - disable 'GradleCompatible' - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } -} - -repositories { - google() - mavenCentral() - gradlePluginPortal() - - def found = false - def defaultDir = null - def androidSourcesName = 'React Native sources' - - if (rootProject.ext.has('reactNativeAndroidRoot')) { - defaultDir = rootProject.ext.get('reactNativeAndroidRoot') - } else { - defaultDir = new File( - projectDir, - '/../../../node_modules/react-native/android' - ) - } - - if (defaultDir.exists()) { - maven { - url defaultDir.toString() - name androidSourcesName - } - - logger.info(":${project.name}:reactNativeAndroidRoot ${defaultDir.canonicalPath}") - found = true - } else { - def parentDir = rootProject.projectDir - - 1.upto(5, { - if (found) return true - parentDir = parentDir.parentFile - - def androidSourcesDir = new File( - parentDir, - 'node_modules/react-native' - ) - - def androidPrebuiltBinaryDir = new File( - parentDir, - 'node_modules/react-native/android' - ) - - if (androidPrebuiltBinaryDir.exists()) { - maven { - url androidPrebuiltBinaryDir.toString() - name androidSourcesName - } - - logger.info(":${project.name}:reactNativeAndroidRoot ${androidPrebuiltBinaryDir.canonicalPath}") - found = true - } else if (androidSourcesDir.exists()) { - maven { - url androidSourcesDir.toString() - name androidSourcesName - } - - logger.info(":${project.name}:reactNativeAndroidRoot ${androidSourcesDir.canonicalPath}") - found = true - } - }) - } - - if (!found) { - throw new GradleException( - "${project.name}: unable to locate React Native android sources. " + - "Ensure you have you installed React Native as a dependency in your project and try again." - ) - } -} - -def kotlin_version = getExtOrDefault('kotlinVersion') - -dependencies { - // noinspection GradleDynamicVersion - api 'com.facebook.react:react-native:+' - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - compileOnly files('libs/sifir_android.aar') -}