diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 808baf278..37564f17e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,6 +44,7 @@ + @@ -58,35 +59,49 @@ - + - - + + + + + + + + + + + android:name="im.conversations.android.ui.activity.MainActivity" + android:exported="true"> + \ No newline at end of file diff --git a/app/src/main/java/im/conversations/android/Conversations.java b/app/src/main/java/im/conversations/android/Conversations.java index d11f705b8..c7982790b 100644 --- a/app/src/main/java/im/conversations/android/Conversations.java +++ b/app/src/main/java/im/conversations/android/Conversations.java @@ -4,6 +4,7 @@ import android.app.Application; import androidx.appcompat.app.AppCompatDelegate; import com.google.android.material.color.DynamicColors; import im.conversations.android.dns.Resolver; +import im.conversations.android.notification.Channels; import im.conversations.android.xmpp.ConnectionPool; import java.security.SecureRandom; import java.security.Security; @@ -25,6 +26,8 @@ public class Conversations extends Application { } catch (final Throwable throwable) { LOGGER.warn("Could not initialize security provider", throwable); } + final var channels = new Channels(this); + channels.initialize(); Resolver.init(this); ConnectionPool.getInstance(this).reconfigure(); AppCompatDelegate.setDefaultNightMode( diff --git a/app/src/main/java/im/conversations/android/notification/Channels.java b/app/src/main/java/im/conversations/android/notification/Channels.java new file mode 100644 index 000000000..af671a94b --- /dev/null +++ b/app/src/main/java/im/conversations/android/notification/Channels.java @@ -0,0 +1,58 @@ +package im.conversations.android.notification; + +import android.app.Application; +import android.app.NotificationChannel; +import android.app.NotificationChannelGroup; +import android.app.NotificationManager; +import android.os.Build; +import androidx.annotation.RequiresApi; +import androidx.core.content.ContextCompat; +import im.conversations.android.R; + +public final class Channels { + + private final Application application; + + private static final String CHANNEL_GROUP_STATUS = "status"; + static final String CHANNEL_FOREGROUND = "foreground"; + + public Channels(final Application application) { + this.application = application; + } + + public void initialize() { + final var notificationManager = + ContextCompat.getSystemService(application, NotificationManager.class); + if (notificationManager == null) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.initializeGroups(notificationManager); + this.initializeForegroundChannel(notificationManager); + } + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private void initializeGroups(NotificationManager notificationManager) { + notificationManager.createNotificationChannelGroup( + new NotificationChannelGroup( + CHANNEL_GROUP_STATUS, + application.getString(R.string.notification_group_status_information))); + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private void initializeForegroundChannel(final NotificationManager notificationManager) { + final NotificationChannel foregroundServiceChannel = + new NotificationChannel( + CHANNEL_FOREGROUND, + application.getString(R.string.foreground_service_channel_name), + NotificationManager.IMPORTANCE_MIN); + foregroundServiceChannel.setDescription( + application.getString( + R.string.foreground_service_channel_description, + application.getString(R.string.app_name))); + foregroundServiceChannel.setShowBadge(false); + foregroundServiceChannel.setGroup(CHANNEL_GROUP_STATUS); + notificationManager.createNotificationChannel(foregroundServiceChannel); + } +} diff --git a/app/src/main/java/im/conversations/android/notification/ForegroundServiceNotification.java b/app/src/main/java/im/conversations/android/notification/ForegroundServiceNotification.java new file mode 100644 index 000000000..ded7b2f90 --- /dev/null +++ b/app/src/main/java/im/conversations/android/notification/ForegroundServiceNotification.java @@ -0,0 +1,63 @@ +package im.conversations.android.notification; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.Build; + +import androidx.core.content.ContextCompat; + +import im.conversations.android.R; +import im.conversations.android.ui.activity.MainActivity; +import im.conversations.android.xmpp.ConnectionPool; + +public class ForegroundServiceNotification { + + public static final int ID = 1; + + private final Service service; + + public ForegroundServiceNotification(final Service service) { + this.service = service; + } + + public Notification build(final ConnectionPool.Summary summary) { + final Notification.Builder builder = new Notification.Builder(service); + builder.setContentTitle(service.getString(R.string.app_name)); + builder.setContentText( + service.getString(R.string.connected_accounts, summary.connected, summary.total)); + builder.setContentIntent(buildPendingIntent()); + builder.setWhen(0) + .setPriority(Notification.PRIORITY_MIN) + .setSmallIcon( + summary.isConnected() + ? R.drawable.ic_link_24dp + : R.drawable.ic_link_off_24dp) + .setLocalOnly(true); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder.setChannelId(Channels.CHANNEL_FOREGROUND); + } + + return builder.build(); + } + + private PendingIntent buildPendingIntent() { + return PendingIntent.getActivity( + service, + 0, + new Intent(service, MainActivity.class), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + } + + public void update(final ConnectionPool.Summary summary) { + final var notificationManager = ContextCompat.getSystemService(service, NotificationManager.class); + if (notificationManager == null) { + return; + } + final var notification = build(summary); + notificationManager.notify(ID, notification); + } +} diff --git a/app/src/main/java/im/conversations/android/receiver/EventReceiver.java b/app/src/main/java/im/conversations/android/receiver/EventReceiver.java new file mode 100644 index 000000000..c7b01b9a3 --- /dev/null +++ b/app/src/main/java/im/conversations/android/receiver/EventReceiver.java @@ -0,0 +1,20 @@ +package im.conversations.android.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import androidx.core.content.ContextCompat; +import im.conversations.android.service.ForegroundService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class EventReceiver extends BroadcastReceiver { + + private static final Logger LOGGER = LoggerFactory.getLogger(EventReceiver.class); + + @Override + public void onReceive(Context context, Intent intent) { + LOGGER.info("Received event {}", intent.getAction()); + ForegroundService.start(context); + } +} diff --git a/app/src/main/java/im/conversations/android/service/ForegroundService.java b/app/src/main/java/im/conversations/android/service/ForegroundService.java new file mode 100644 index 000000000..b0eb8dcfa --- /dev/null +++ b/app/src/main/java/im/conversations/android/service/ForegroundService.java @@ -0,0 +1,49 @@ +package im.conversations.android.service; + +import android.content.Context; +import android.content.Intent; + +import androidx.core.content.ContextCompat; +import androidx.lifecycle.LifecycleService; +import im.conversations.android.notification.ForegroundServiceNotification; +import im.conversations.android.xmpp.ConnectionPool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ForegroundService extends LifecycleService { + + private static final Logger LOGGER = LoggerFactory.getLogger(ForegroundService.class); + + private final ForegroundServiceNotification foregroundServiceNotification = + new ForegroundServiceNotification(this); + + @Override + public void onCreate() { + super.onCreate(); + LOGGER.info("Creating service"); + final var pool = ConnectionPool.getInstance(this); + startForeground( + ForegroundServiceNotification.ID, + foregroundServiceNotification.build(pool.buildSummary())); + pool.setSummaryProcessor(this::onSummaryUpdated); + } + + private void onSummaryUpdated(final ConnectionPool.Summary summary) { + foregroundServiceNotification.update(summary); + } + + @Override + public void onDestroy() { + super.onDestroy(); + LOGGER.debug("Destroying service. Removing listeners"); + ConnectionPool.getInstance(this).setSummaryProcessor(null); + } + + public static void start(final Context context) { + try { + ContextCompat.startForegroundService(context, new Intent(context, ForegroundService.class)); + } catch (final RuntimeException e) { + LOGGER.error("Could not start foreground service", e); + } + } +} diff --git a/app/src/main/java/im/conversations/android/ui/activity/MainActivity.java b/app/src/main/java/im/conversations/android/ui/activity/MainActivity.java new file mode 100644 index 000000000..ab1cd4df6 --- /dev/null +++ b/app/src/main/java/im/conversations/android/ui/activity/MainActivity.java @@ -0,0 +1,15 @@ +package im.conversations.android.ui.activity; + +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; + +import im.conversations.android.service.ForegroundService; + +public class MainActivity extends AppCompatActivity { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ForegroundService.start(this); + } +} diff --git a/app/src/main/java/im/conversations/android/xmpp/ConnectionPool.java b/app/src/main/java/im/conversations/android/xmpp/ConnectionPool.java index 6409aca8d..f6d426d1a 100644 --- a/app/src/main/java/im/conversations/android/xmpp/ConnectionPool.java +++ b/app/src/main/java/im/conversations/android/xmpp/ConnectionPool.java @@ -3,6 +3,7 @@ package im.conversations.android.xmpp; import android.content.Context; import android.os.SystemClock; import com.google.common.base.Optional; +import com.google.common.collect.Collections2; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; @@ -21,6 +22,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import org.jxmpp.jid.BareJid; import org.jxmpp.jid.Jid; import org.slf4j.Logger; @@ -48,6 +50,8 @@ public class ConnectionPool { private final List connections = new ArrayList<>(); private final HashSet lowPingTimeoutMode = new HashSet<>(); + private Consumer summaryProcessor; + private ConnectionPool(final Context context) { this.context = context.getApplicationContext(); } @@ -207,10 +211,30 @@ public class ConnectionPool { } } } + this.updateSummaryProcessor(); // TODO toggle error notification // getNotificationService().updateErrorNotification(); } + private void updateSummaryProcessor() { + final var processor = this.summaryProcessor; + if (processor == null) { + return; + } + processor.accept(buildSummary()); + } + + public synchronized Summary buildSummary() { + final int connected = + Collections2.filter(this.connections, c -> c.getStatus() == ConnectionState.ONLINE) + .size(); + return new Summary(this.connections.size(), connected); + } + + public void setSummaryProcessor(final Consumer processor) { + this.summaryProcessor = processor; + } + public void scheduleWakeUpCall(final int seconds) { CONNECTION_SCHEDULER.schedule( () -> { @@ -372,6 +396,20 @@ public class ConnectionPool { return ImmutableSet.copyOf(Lists.transform(this.connections, XmppConnection::getAccount)); } + public static final class Summary { + public final int total; + public final int connected; + + public Summary(int total, int connected) { + this.total = total; + this.connected = connected; + } + + public boolean isConnected() { + return total > 0 && total == connected; + } + } + public static ConnectionPool getInstance(final Context context) { if (INSTANCE != null) { return INSTANCE; diff --git a/app/src/main/res/drawable/ic_link_24dp.xml b/app/src/main/res/drawable/ic_link_24dp.xml new file mode 100644 index 000000000..ccb142a61 --- /dev/null +++ b/app/src/main/res/drawable/ic_link_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_link_off_24dp.xml b/app/src/main/res/drawable/ic_link_off_24dp.xml new file mode 100644 index 000000000..a6bf67c09 --- /dev/null +++ b/app/src/main/res/drawable/ic_link_off_24dp.xml @@ -0,0 +1,10 @@ + + +