diff --git a/.project b/.project new file mode 100644 index 0000000..3964dd3 --- /dev/null +++ b/.project @@ -0,0 +1,17 @@ + + + android + Project android created by Buildship. + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + + org.eclipse.buildship.core.gradleprojectnature + + diff --git a/app/build.gradle b/app/build.gradle index de7d26a..0c2c47a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.github.gotify" minSdkVersion 19 targetSdkVersion 29 - versionCode 15 - versionName "2.0.12" + versionCode 17 + versionName "2.0.14" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3dfbef8..9cc4491 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true" android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config" android:theme="@style/AppTheme"> + + + + + + + @@ -61,4 +72,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/com/github/gotify/MissedMessageUtil.java b/app/src/main/java/com/github/gotify/MissedMessageUtil.java index 661c63d..d197830 100644 --- a/app/src/main/java/com/github/gotify/MissedMessageUtil.java +++ b/app/src/main/java/com/github/gotify/MissedMessageUtil.java @@ -14,7 +14,7 @@ import java.util.List; import static com.github.gotify.api.Callback.call; public class MissedMessageUtil { - static final int NO_MESSAGES = 0; + static final long NO_MESSAGES = 0; private final MessageApi api; @@ -22,8 +22,8 @@ public class MissedMessageUtil { this.api = api; } - public void lastReceivedMessage(Callback.SuccessCallback successCallback) { - api.getMessages(1, 0) + public void lastReceivedMessage(Callback.SuccessCallback successCallback) { + api.getMessages(1, 0L) .enqueue( call( (messages) -> { @@ -37,11 +37,11 @@ public class MissedMessageUtil { (e) -> {})); } - public List missingMessages(int till) { + public List missingMessages(long till) { List result = new ArrayList<>(); try { - Integer since = null; + Long since = null; while (true) { PagedMessages pagedMessages = Api.execute(api.getMessages(10, since)); List messages = pagedMessages.getMessages(); @@ -61,7 +61,7 @@ public class MissedMessageUtil { return result; } - private List filter(List messages, int till) { + private List filter(List messages, long till) { List result = new ArrayList<>(); for (Message message : messages) { diff --git a/app/src/main/java/com/github/gotify/Utils.java b/app/src/main/java/com/github/gotify/Utils.java index 739c5a1..2a3dd48 100644 --- a/app/src/main/java/com/github/gotify/Utils.java +++ b/app/src/main/java/com/github/gotify/Utils.java @@ -33,6 +33,10 @@ public class Utils { Snackbar.make(rootView, message, Snackbar.LENGTH_SHORT).show(); } + public static int longToInt(long value) { + return (int) (value % Integer.MAX_VALUE); + } + public static String dateToRelative(OffsetDateTime data) { long time = data.toInstant().toEpochMilli(); long now = System.currentTimeMillis(); @@ -96,4 +100,11 @@ public class Utils { if (str == null) return null; return new Buffer().writeUtf8(str).inputStream(); } + + public static T first(T[] data) { + if (data.length != 1) { + throw new IllegalArgumentException("must be one element"); + } + return data[0]; + } } diff --git a/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.java b/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.java index 3c6da03..02aad06 100644 --- a/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.java +++ b/app/src/main/java/com/github/gotify/messages/ListMessageAdapter.java @@ -1,5 +1,7 @@ package com.github.gotify.messages; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Context; import android.text.util.Linkify; import android.view.LayoutInflater; @@ -8,6 +10,7 @@ import android.view.ViewGroup; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import butterknife.BindView; @@ -133,6 +136,7 @@ public class ListMessageAdapter extends RecyclerView.Adapter { + ClipboardManager clipboard = + (ClipboardManager) + view.getContext() + .getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = + ClipData.newPlainText( + "GotifyMessageContent", message.getText().toString()); + + if (clipboard != null) { + clipboard.setPrimaryClip(clip); + Toast toast = + Toast.makeText( + view.getContext(), + view.getContext() + .getString( + R.string.message_copied_to_clipboard), + Toast.LENGTH_SHORT); + toast.show(); + } + + return true; + }); + } } public interface Delete { diff --git a/app/src/main/java/com/github/gotify/messages/MessagesActivity.java b/app/src/main/java/com/github/gotify/messages/MessagesActivity.java index 3522dd5..0b680a1 100644 --- a/app/src/main/java/com/github/gotify/messages/MessagesActivity.java +++ b/app/src/main/java/com/github/gotify/messages/MessagesActivity.java @@ -43,7 +43,6 @@ import com.github.gotify.Settings; import com.github.gotify.Utils; import com.github.gotify.api.Api; import com.github.gotify.api.ApiException; -import com.github.gotify.api.CertUtils; import com.github.gotify.api.ClientFactory; import com.github.gotify.client.ApiClient; import com.github.gotify.client.api.ClientApi; @@ -60,23 +59,20 @@ import com.github.gotify.messages.provider.MessageDeletion; import com.github.gotify.messages.provider.MessageFacade; import com.github.gotify.messages.provider.MessageState; import com.github.gotify.messages.provider.MessageWithImage; -import com.github.gotify.picasso.PicassoDataRequestHandler; +import com.github.gotify.picasso.PicassoHandler; import com.github.gotify.service.WebSocketService; import com.github.gotify.settings.SettingsActivity; +import com.github.gotify.sharing.ShareActivity; import com.google.android.material.navigation.NavigationView; import com.google.android.material.snackbar.BaseTransientBottomBar; import com.google.android.material.snackbar.Snackbar; -import com.squareup.picasso.OkHttp3Downloader; -import com.squareup.picasso.Picasso; import com.squareup.picasso.Target; -import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import okhttp3.Cache; -import okhttp3.OkHttpClient; +import static com.github.gotify.Utils.first; import static java.util.Collections.emptyList; public class MessagesActivity extends AppCompatActivity @@ -118,14 +114,12 @@ public class MessagesActivity extends AppCompatActivity private Settings settings; protected ApplicationHolder appsHolder; - private int appId = MessageState.ALL_MESSAGES; + private long appId = MessageState.ALL_MESSAGES; private boolean isLoadMore = false; - private Integer selectAppIdOnDrawerClose = null; + private Long selectAppIdOnDrawerClose = null; - int PICASSO_CACHE_SIZE = 50 * 1024 * 1024; // 50 MB - private Cache picassoCache; - private Picasso picasso; + private PicassoHandler picassoHandler; // we need to keep the target references otherwise they get gc'ed before they can be called. @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") @@ -139,8 +133,7 @@ public class MessagesActivity extends AppCompatActivity Log.i("Entering " + getClass().getSimpleName()); settings = new Settings(this); - picassoCache = new Cache(new File(getCacheDir(), "picasso-cache"), PICASSO_CACHE_SIZE); - picasso = makePicasso(); + picassoHandler = new PicassoHandler(this, settings); client = ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token()); @@ -157,7 +150,7 @@ public class MessagesActivity extends AppCompatActivity messagesView.getContext(), layoutManager.getOrientation()); ListMessageAdapter adapter = new ListMessageAdapter( - this, settings, picasso, emptyList(), this::scheduleDeletion); + this, settings, picassoHandler.get(), emptyList(), this::scheduleDeletion); messagesView.addItemDecoration(dividerItemDecoration); messagesView.setHasFixedSize(true); @@ -200,7 +193,7 @@ public class MessagesActivity extends AppCompatActivity public void onRefreshAll(View view) { try { - picassoCache.evictAll(); + picassoHandler.evict(); } catch (IOException e) { Log.e("Problem evicting Picasso cache", e); } @@ -229,12 +222,15 @@ public class MessagesActivity extends AppCompatActivity menu.removeGroup(R.id.apps); targetReferences.clear(); updateMessagesAndStopLoading(messages.get(appId)); - for (Application app : applications) { - MenuItem item = menu.add(R.id.apps, app.getId(), APPLICATION_ORDER, app.getName()); + for (int i = 0; i < applications.size(); i++) { + Application app = applications.get(i); + MenuItem item = menu.add(R.id.apps, i, APPLICATION_ORDER, app.getName()); item.setCheckable(true); Target t = Utils.toDrawable(getResources(), item::setIcon); targetReferences.add(t); - picasso.load(Utils.resolveAbsoluteUrl(settings.url() + "/", app.getImage())) + picassoHandler + .get() + .load(Utils.resolveAbsoluteUrl(settings.url() + "/", app.getImage())) .error(R.drawable.ic_alarm) .placeholder(R.drawable.ic_placeholder) .resize(100, 100) @@ -242,20 +238,6 @@ public class MessagesActivity extends AppCompatActivity } } - private Picasso makePicasso() { - OkHttpClient.Builder builder = new OkHttpClient.Builder(); - builder.cache(picassoCache); - - CertUtils.applySslSettings(builder, settings.sslSettings()); - - OkHttp3Downloader downloader = new OkHttp3Downloader(builder.build()); - - return new Picasso.Builder(this) - .addRequestHandler(new PicassoDataRequestHandler()) - .downloader(downloader) - .build(); - } - private void initDrawer() { setSupportActionBar(toolbar); navigationView.setItemIconTintList(null); @@ -303,7 +285,8 @@ public class MessagesActivity extends AppCompatActivity int id = item.getItemId(); if (item.getGroupId() == R.id.apps) { - selectAppIdOnDrawerClose = id; + Application app = appsHolder.get().get(id); + selectAppIdOnDrawerClose = app != null ? app.getId() : MessageState.ALL_MESSAGES; startLoading(); toolbar.setSubtitle(item.getTitle()); } else if (id == R.id.nav_all_messages) { @@ -321,6 +304,9 @@ public class MessagesActivity extends AppCompatActivity startActivity(new Intent(this, LogsActivity.class)); } else if (id == R.id.settings) { startActivity(new Intent(this, SettingsActivity.class)); + } else if (id == R.id.push_message) { + Intent intent = new Intent(MessagesActivity.this, ShareActivity.class); + startActivity(intent); } drawer.closeDrawer(GravityCompat.START); @@ -330,7 +316,6 @@ public class MessagesActivity extends AppCompatActivity public void doLogout(DialogInterface dialog, int which) { setContentView(R.layout.splash); new DeleteClientAndNavigateToLogin().execute(); - finish(); } private void startLoading() { @@ -355,10 +340,17 @@ public class MessagesActivity extends AppCompatActivity filter.addAction(WebSocketService.NEW_MESSAGE_BROADCAST); registerReceiver(receiver, filter); new UpdateMissedMessages().execute(messages.getLastReceivedMessage()); - navigationView - .getMenu() - .findItem(appId == MessageState.ALL_MESSAGES ? R.id.nav_all_messages : appId) - .setChecked(true); + + int selectedIndex = R.id.nav_all_messages; + if (appId != MessageState.ALL_MESSAGES) { + for (int i = 0; i < appsHolder.get().size(); i++) { + if (appsHolder.get().get(i).getId() == appId) { + selectedIndex = i; + } + } + } + + navigationView.getMenu().findItem(selectedIndex).setChecked(true); super.onResume(); } @@ -371,7 +363,7 @@ public class MessagesActivity extends AppCompatActivity @Override protected void onDestroy() { super.onDestroy(); - picasso.shutdown(); + picassoHandler.get().shutdown(); } private void scheduleDeletion(int position, Message message, boolean listAnimation) { @@ -542,10 +534,10 @@ public class MessagesActivity extends AppCompatActivity } } - private class UpdateMissedMessages extends AsyncTask { + private class UpdateMissedMessages extends AsyncTask { @Override - protected Boolean doInBackground(Integer... ids) { - Integer id = first(ids); + protected Boolean doInBackground(Long... ids) { + Long id = first(ids); if (id == -1) { return false; } @@ -579,10 +571,10 @@ public class MessagesActivity extends AppCompatActivity return super.onContextItemSelected(item); } - private class LoadMore extends AsyncTask> { + private class LoadMore extends AsyncTask> { @Override - protected List doInBackground(Integer... appId) { + protected List doInBackground(Long... appId) { return messages.loadMore(first(appId)); } @@ -592,7 +584,7 @@ public class MessagesActivity extends AppCompatActivity } } - private class SelectApplicationAndUpdateMessages extends AsyncTask { + private class SelectApplicationAndUpdateMessages extends AsyncTask { private SelectApplicationAndUpdateMessages(boolean withLoadingSpinner) { if (withLoadingSpinner) { @@ -601,14 +593,14 @@ public class MessagesActivity extends AppCompatActivity } @Override - protected Integer doInBackground(Integer... appIds) { - Integer appId = first(appIds); + protected Long doInBackground(Long... appIds) { + Long appId = first(appIds); messages.loadMoreIfNotPresent(appId); return appId; } @Override - protected void onPostExecute(Integer appId) { + protected void onPostExecute(Long appId) { updateMessagesAndStopLoading(messages.get(appId)); } } @@ -641,14 +633,14 @@ public class MessagesActivity extends AppCompatActivity } } - private class DeleteMessages extends AsyncTask { + private class DeleteMessages extends AsyncTask { DeleteMessages() { startLoading(); } @Override - protected Boolean doInBackground(Integer... appId) { + protected Boolean doInBackground(Long... appId) { return messages.deleteAll(first(appId)); } @@ -683,7 +675,7 @@ public class MessagesActivity extends AppCompatActivity if (currentClient != null) { Log.i("Delete client with id " + currentClient.getId()); - api.deleteClient(currentClient.getId()); + Api.execute(api.deleteClient(currentClient.getId())); } else { Log.e("Could not delete client, client does not exist."); } @@ -717,12 +709,4 @@ public class MessagesActivity extends AppCompatActivity adapter.setItems(messageWithImages); adapter.notifyDataSetChanged(); } - - private T first(T[] data) { - if (data.length != 1) { - throw new IllegalArgumentException("must be one element"); - } - - return data[0]; - } } diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.java b/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.java index cacafb8..1252576 100644 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.java +++ b/app/src/main/java/com/github/gotify/messages/provider/MessageFacade.java @@ -18,7 +18,7 @@ public class MessageFacade { this.state = new MessageStateHolder(); } - public synchronized List get(Integer appId) { + public synchronized List get(long appId) { return combiner.combine(state.state(appId).messages, applicationHolder.get()); } @@ -28,7 +28,7 @@ public class MessageFacade { } } - public synchronized List loadMore(Integer appId) { + public synchronized List loadMore(long appId) { MessageState state = this.state.state(appId); if (state.hasNext || !state.loaded) { PagedMessages pagedMessages = requester.loadMore(state); @@ -37,7 +37,7 @@ public class MessageFacade { return get(appId); } - public synchronized void loadMoreIfNotPresent(Integer appId) { + public synchronized void loadMoreIfNotPresent(long appId) { MessageState state = this.state.state(appId); if (!state.loaded) { loadMore(appId); @@ -48,7 +48,7 @@ public class MessageFacade { this.state.clear(); } - public int getLastReceivedMessage() { + public long getLastReceivedMessage() { return state.getLastReceivedMessage(); } @@ -70,13 +70,13 @@ public class MessageFacade { return this.state.undoPendingDeletion(); } - public synchronized boolean deleteAll(Integer appId) { + public synchronized boolean deleteAll(long appId) { boolean success = this.requester.deleteAll(appId); this.state.deleteAll(appId); return success; } - public synchronized boolean canLoadMore(Integer appId) { + public synchronized boolean canLoadMore(long appId) { return state.state(appId).hasNext; } } diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.java b/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.java index 4a145f6..a258b02 100644 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.java +++ b/app/src/main/java/com/github/gotify/messages/provider/MessageImageCombiner.java @@ -3,14 +3,14 @@ package com.github.gotify.messages.provider; import com.github.gotify.client.model.Application; import com.github.gotify.client.model.Message; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; -class MessageImageCombiner { +public class MessageImageCombiner { List combine(List messages, List applications) { - Map appIdToImage = appIdToImage(applications); + Map appIdToImage = appIdToImage(applications); List result = new ArrayList<>(); @@ -26,8 +26,8 @@ class MessageImageCombiner { return result; } - private Map appIdToImage(List applications) { - Map map = new HashMap<>(); + public static Map appIdToImage(List applications) { + Map map = new ConcurrentHashMap<>(); for (Application app : applications) { map.put(app.getId(), app.getImage()); } diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.java b/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.java index 8f07fdc..707ffc8 100644 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.java +++ b/app/src/main/java/com/github/gotify/messages/provider/MessageRequester.java @@ -35,7 +35,7 @@ class MessageRequester { messageApi.deleteMessage(message.getId()).enqueue(Callback.call()); } - boolean deleteAll(Integer appId) { + boolean deleteAll(Long appId) { try { Log.i("Deleting all messages for " + appId); if (MessageState.ALL_MESSAGES == appId) { diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageState.java b/app/src/main/java/com/github/gotify/messages/provider/MessageState.java index 8c8db1e..d90da5f 100644 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageState.java +++ b/app/src/main/java/com/github/gotify/messages/provider/MessageState.java @@ -5,11 +5,11 @@ import java.util.ArrayList; import java.util.List; public class MessageState { - public static final int ALL_MESSAGES = -1; + public static final long ALL_MESSAGES = -1; - int appId; + long appId; boolean loaded; boolean hasNext; - int nextSince = 0; + long nextSince = 0; List messages = new ArrayList<>(); } diff --git a/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.java b/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.java index 25e37b4..4065d46 100644 --- a/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.java +++ b/app/src/main/java/com/github/gotify/messages/provider/MessageStateHolder.java @@ -6,8 +6,8 @@ import java.util.HashMap; import java.util.Map; class MessageStateHolder { - private int lastReceivedMessage = -1; - private Map states = new HashMap<>(); + private long lastReceivedMessage = -1; + private Map states = new HashMap<>(); private MessageDeletion pendingDeletion = null; @@ -15,7 +15,7 @@ class MessageStateHolder { states = new HashMap<>(); } - synchronized void newMessages(Integer appId, PagedMessages pagedMessages) { + synchronized void newMessages(Long appId, PagedMessages pagedMessages) { MessageState state = state(appId); if (!state.loaded && pagedMessages.getMessages().size() > 0) { @@ -49,7 +49,7 @@ class MessageStateHolder { if (deletion != null) deleteMessage(deletion.getMessage()); } - synchronized MessageState state(Integer appId) { + synchronized MessageState state(Long appId) { MessageState state = states.get(appId); if (state == null) { return emptyState(appId); @@ -57,14 +57,14 @@ class MessageStateHolder { return state; } - synchronized void deleteAll(Integer appId) { + synchronized void deleteAll(Long appId) { clear(); MessageState state = state(appId); state.loaded = true; states.put(appId, state); } - private MessageState emptyState(Integer appId) { + private MessageState emptyState(Long appId) { MessageState emptyState = new MessageState(); emptyState.loaded = false; emptyState.hasNext = false; @@ -73,7 +73,7 @@ class MessageStateHolder { return emptyState; } - synchronized int getLastReceivedMessage() { + synchronized long getLastReceivedMessage() { return lastReceivedMessage; } diff --git a/app/src/main/java/com/github/gotify/picasso/PicassoHandler.java b/app/src/main/java/com/github/gotify/picasso/PicassoHandler.java new file mode 100644 index 0000000..546b06b --- /dev/null +++ b/app/src/main/java/com/github/gotify/picasso/PicassoHandler.java @@ -0,0 +1,94 @@ +package com.github.gotify.picasso; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import com.github.gotify.R; +import com.github.gotify.Settings; +import com.github.gotify.Utils; +import com.github.gotify.api.Callback; +import com.github.gotify.api.CertUtils; +import com.github.gotify.api.ClientFactory; +import com.github.gotify.client.api.ApplicationApi; +import com.github.gotify.log.Log; +import com.github.gotify.messages.provider.MessageImageCombiner; +import com.squareup.picasso.OkHttp3Downloader; +import com.squareup.picasso.Picasso; +import java.io.File; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import okhttp3.Cache; +import okhttp3.OkHttpClient; + +public class PicassoHandler { + + private static final int PICASSO_CACHE_SIZE = 50 * 1024 * 1024; // 50 MB + private static final String PICASSO_CACHE_SUBFOLDER = "picasso-cache"; + + private Context context; + private Settings settings; + + private Cache picassoCache; + + private Picasso picasso; + private Map appIdToAppImage = new ConcurrentHashMap<>(); + + public PicassoHandler(Context context, Settings settings) { + this.context = context; + this.settings = settings; + + picassoCache = + new Cache( + new File(context.getCacheDir(), PICASSO_CACHE_SUBFOLDER), + PICASSO_CACHE_SIZE); + picasso = makePicasso(); + } + + private Picasso makePicasso() { + OkHttpClient.Builder builder = new OkHttpClient.Builder(); + builder.cache(picassoCache); + CertUtils.applySslSettings(builder, settings.sslSettings()); + OkHttp3Downloader downloader = new OkHttp3Downloader(builder.build()); + return new Picasso.Builder(context).downloader(downloader).build(); + } + + public Bitmap getIcon(Long appId) { + if (appId == -1) { + return BitmapFactory.decodeResource(context.getResources(), R.drawable.gotify); + } + + try { + return picasso.load( + Utils.resolveAbsoluteUrl( + settings.url() + "/", appIdToAppImage.get(appId))) + .get(); + } catch (IOException e) { + Log.e("Could not load image for notification", e); + } + return BitmapFactory.decodeResource(context.getResources(), R.drawable.gotify); + } + + public void updateAppIds() { + ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token()) + .createService(ApplicationApi.class) + .getApps() + .enqueue( + Callback.call( + (apps) -> { + appIdToAppImage.clear(); + appIdToAppImage.putAll(MessageImageCombiner.appIdToImage(apps)); + }, + (t) -> { + appIdToAppImage.clear(); + })); + } + + public Picasso get() { + return picasso; + } + + public void evict() throws IOException { + picassoCache.evictAll(); + } +} diff --git a/app/src/main/java/com/github/gotify/service/MessagingDatabase.kt b/app/src/main/java/com/github/gotify/service/MessagingDatabase.kt index 27d4070..71571f9 100644 --- a/app/src/main/java/com/github/gotify/service/MessagingDatabase.kt +++ b/app/src/main/java/com/github/gotify/service/MessagingDatabase.kt @@ -29,7 +29,7 @@ class MessagingDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, n throw IllegalStateException("Upgrades not supported") } - fun registerApp(packageName: String, appId :Int, gotify_token: String, connector_token: String){ + fun registerApp(packageName: String, appId :Long, gotify_token: String, connector_token: String){ val db = writableDatabase val values = ContentValues().apply { put(FIELD_PACKAGE_NAME, packageName) @@ -88,7 +88,7 @@ class MessagingDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, n } } - fun getAppFromId(appId: Int): String{ + fun getAppFromId(appId: Long): String{ val db = readableDatabase val projection = arrayOf(FIELD_PACKAGE_NAME) val selection = "$FIELD_APP_ID = ?" @@ -106,7 +106,7 @@ class MessagingDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, n } } - fun getAppId(packageName: String): Int{ + fun getAppId(packageName: String): Long{ val db = readableDatabase val projection = arrayOf(FIELD_APP_ID) val selection = "$FIELD_PACKAGE_NAME = ?" @@ -120,7 +120,7 @@ class MessagingDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, n null, null ).use { cursor -> - if (cursor.moveToFirst()) cursor.getInt(cursor.getColumnIndex(FIELD_APP_ID)) else -1 + if (cursor.moveToFirst()) cursor.getLong(cursor.getColumnIndex(FIELD_APP_ID)) else -1 } } diff --git a/app/src/main/java/com/github/gotify/service/WebSocketConnection.java b/app/src/main/java/com/github/gotify/service/WebSocketConnection.java index ebda4da..2810128 100644 --- a/app/src/main/java/com/github/gotify/service/WebSocketConnection.java +++ b/app/src/main/java/com/github/gotify/service/WebSocketConnection.java @@ -1,7 +1,9 @@ package com.github.gotify.service; +import android.app.AlarmManager; import android.net.ConnectivityManager; import android.net.NetworkInfo; +import android.os.Build; import android.os.Handler; import com.github.gotify.SSLSettings; import com.github.gotify.Utils; @@ -9,7 +11,9 @@ import com.github.gotify.api.Callback; import com.github.gotify.api.CertUtils; import com.github.gotify.client.model.Message; import com.github.gotify.log.Log; +import java.util.Calendar; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -17,8 +21,10 @@ import okhttp3.Response; import okhttp3.WebSocket; import okhttp3.WebSocketListener; -public class WebSocketConnection { +class WebSocketConnection { + private static final AtomicLong ID = new AtomicLong(0); private final ConnectivityManager connectivityManager; + private final AlarmManager alarmManager; private OkHttpClient client; private final Handler reconnectHandler = new Handler(); @@ -34,15 +40,17 @@ public class WebSocketConnection { private BadRequestRunnable onBadRequest; private OnNetworkFailureRunnable onNetworkFailure; private Runnable onReconnected; - private boolean isClosed; + private State state; private Runnable onDisconnect; WebSocketConnection( String baseUrl, SSLSettings settings, String token, - ConnectivityManager connectivityManager) { + ConnectivityManager connectivityManager, + AlarmManager alarmManager) { this.connectivityManager = connectivityManager; + this.alarmManager = alarmManager; OkHttpClient.Builder builder = new OkHttpClient.Builder() .readTimeout(0, TimeUnit.MILLISECONDS) @@ -102,67 +110,97 @@ public class WebSocketConnection { } public synchronized WebSocketConnection start() { + if (state == State.Connecting || state == State.Connected) { + return this; + } close(); - isClosed = false; - Log.i("WebSocket: starting..."); + state = State.Connecting; + long nextId = ID.incrementAndGet(); + Log.i("WebSocket(" + nextId + "): starting..."); - webSocket = client.newWebSocket(request(), new Listener()); + webSocket = client.newWebSocket(request(), new Listener(nextId)); return this; } public synchronized void close() { if (webSocket != null) { - Log.i("WebSocket: closing existing connection."); - isClosed = true; + Log.i("WebSocket(" + ID.get() + "): closing existing connection."); + state = State.Disconnected; webSocket.close(1000, ""); webSocket = null; } } - public synchronized void scheduleReconnect(long millis) { - reconnectHandler.removeCallbacks(reconnectCallback); + public synchronized void scheduleReconnect(long seconds) { + if (state == State.Connecting || state == State.Connected) { + return; + } + state = State.Scheduled; - Log.i( - "WebSocket: scheduling a restart in " - + TimeUnit.SECONDS.convert(millis, TimeUnit.MILLISECONDS) - + " second(s)"); - reconnectHandler.postDelayed(reconnectCallback, millis); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Log.i( + "WebSocket: scheduling a restart in " + + seconds + + " second(s) (via alarm manager)"); + final Calendar future = Calendar.getInstance(); + future.add(Calendar.SECOND, (int) seconds); + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + future.getTimeInMillis(), + "reconnect-tag", + this::start, + null); + } else { + Log.i("WebSocket: scheduling a restart in " + seconds + " second(s)"); + reconnectHandler.removeCallbacks(reconnectCallback); + reconnectHandler.postDelayed(reconnectCallback, TimeUnit.SECONDS.toMillis(seconds)); + } } private class Listener extends WebSocketListener { + private final long id; + + public Listener(long id) { + this.id = id; + } @Override public void onOpen(WebSocket webSocket, Response response) { - Log.i("WebSocket: opened"); - synchronized (this) { - onOpen.run(); + syncExec( + () -> { + state = State.Connected; + Log.i("WebSocket(" + id + "): opened"); + onOpen.run(); - if (errorCount > 0) { - onReconnected.run(); - errorCount = 0; - } - } + if (errorCount > 0) { + onReconnected.run(); + errorCount = 0; + } + }); super.onOpen(webSocket, response); } @Override public void onMessage(WebSocket webSocket, String text) { - Log.i("WebSocket: received message " + text); - synchronized (this) { - Message message = Utils.JSON.fromJson(text, Message.class); - onMessage.onSuccess(message); - } + syncExec( + () -> { + Log.i("WebSocket(" + id + "): received message " + text); + Message message = Utils.JSON.fromJson(text, Message.class); + onMessage.onSuccess(message); + }); super.onMessage(webSocket, text); } @Override public void onClosed(WebSocket webSocket, int code, String reason) { - synchronized (this) { - if (!isClosed) { - Log.w("WebSocket: closed"); - onClose.run(); - } - } + syncExec( + () -> { + if (state == State.Connected) { + Log.w("WebSocket(" + id + "): closed"); + onClose.run(); + } + state = State.Disconnected; + }); super.onClosed(webSocket, code, reason); } @@ -171,31 +209,41 @@ public class WebSocketConnection { public void onFailure(WebSocket webSocket, Throwable t, Response response) { String code = response != null ? "StatusCode: " + response.code() : ""; String message = response != null ? response.message() : ""; - Log.e("WebSocket: failure " + code + " Message: " + message, t); - synchronized (this) { - if (response != null && response.code() >= 400 && response.code() <= 499) { - onBadRequest.execute(message); - close(); - return; - } + Log.e("WebSocket(" + id + "): failure " + code + " Message: " + message, t); + syncExec( + () -> { + state = State.Disconnected; + if (response != null && response.code() >= 400 && response.code() <= 499) { + onBadRequest.execute(message); + close(); + return; + } - errorCount++; + errorCount++; - NetworkInfo network = connectivityManager.getActiveNetworkInfo(); - if (network == null || !network.isConnected()) { - Log.i("WebSocket: Network not connected"); - onDisconnect.run(); - return; - } + NetworkInfo network = connectivityManager.getActiveNetworkInfo(); + if (network == null || !network.isConnected()) { + Log.i("WebSocket(" + id + "): Network not connected"); + onDisconnect.run(); + return; + } - int minutes = Math.min(errorCount * 2 - 1, 20); + int minutes = Math.min(errorCount * 2 - 1, 20); - onNetworkFailure.execute(minutes); - scheduleReconnect(TimeUnit.MINUTES.toMillis(minutes)); - } + onNetworkFailure.execute(minutes); + scheduleReconnect(TimeUnit.MINUTES.toSeconds(minutes)); + }); super.onFailure(webSocket, t, response); } + + private void syncExec(Runnable runnable) { + synchronized (this) { + if (ID.get() == id) { + runnable.run(); + } + } + } } interface BadRequestRunnable { @@ -205,4 +253,11 @@ public class WebSocketConnection { interface OnNetworkFailureRunnable { void execute(long millis); } + + enum State { + Scheduled, + Connecting, + Connected, + Disconnected + } } diff --git a/app/src/main/java/com/github/gotify/service/WebSocketService.java b/app/src/main/java/com/github/gotify/service/WebSocketService.java index b395793..9da5ef8 100644 --- a/app/src/main/java/com/github/gotify/service/WebSocketService.java +++ b/app/src/main/java/com/github/gotify/service/WebSocketService.java @@ -1,5 +1,6 @@ package com.github.gotify.service; +import android.app.AlarmManager; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; @@ -22,40 +23,44 @@ import com.github.gotify.R; import com.github.gotify.Settings; import com.github.gotify.Utils; import com.github.gotify.api.ClientFactory; +import com.github.gotify.client.ApiClient; import com.github.gotify.client.api.MessageApi; import com.github.gotify.client.model.Message; import com.github.gotify.log.Log; import com.github.gotify.log.UncaughtExceptionHandler; import com.github.gotify.messages.Extras; import com.github.gotify.messages.MessagesActivity; +import com.github.gotify.picasso.PicassoHandler; import java.util.List; import java.util.Map; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static com.github.gotify.api.Callback.call; public class WebSocketService extends Service { public static final String NEW_MESSAGE_BROADCAST = WebSocketService.class.getName() + ".NEW_MESSAGE"; - private static final int NOT_LOADED = -2; + private static final long NOT_LOADED = -2; private Settings settings; private WebSocketConnection connection; - private AtomicInteger lastReceivedMessage = new AtomicInteger(NOT_LOADED); + private AtomicLong lastReceivedMessage = new AtomicLong(NOT_LOADED); private MissedMessageUtil missingMessageUtil; + private PicassoHandler picassoHandler; + @Override public void onCreate() { super.onCreate(); settings = new Settings(this); - missingMessageUtil = - new MissedMessageUtil( - ClientFactory.clientToken( - settings.url(), settings.sslSettings(), settings.token()) - .createService(MessageApi.class)); + ApiClient client = + ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token()); + missingMessageUtil = new MissedMessageUtil(client.createService(MessageApi.class)); Log.i("Create " + getClass().getSimpleName()); + picassoHandler = new PicassoHandler(this, settings); } @Override @@ -92,12 +97,17 @@ public class WebSocketService extends Service { ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); connection = new WebSocketConnection( - settings.url(), settings.sslSettings(), settings.token(), cm) + settings.url(), + settings.sslSettings(), + settings.token(), + cm, + alarmManager) .onOpen(this::onOpen) - .onClose(() -> foreground(getString(R.string.websocket_closed))) + .onClose(this::onClose) .onBadRequest(this::onBadRequest) .onNetworkFailure( (min) -> foreground(getString(R.string.websocket_failed, min))) @@ -110,6 +120,26 @@ public class WebSocketService extends Service { intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); ReconnectListener receiver = new ReconnectListener(this::doReconnect); registerReceiver(receiver, intentFilter); + + picassoHandler.updateAppIds(); + } + + private void onClose() { + foreground(getString(R.string.websocket_closed_try_reconnect)); + ClientFactory.userApiWithToken(settings) + .currentUser() + .enqueue( + call( + (ignored) -> this.doReconnect(), + (exception) -> { + if (exception.code() == 401) { + foreground(getString(R.string.websocket_closed_logout)); + } else { + Log.i( + "WebSocket closed but the user still authenticated, trying to reconnect"); + this.doReconnect(); + } + })); } private void onDisconnect() { @@ -121,7 +151,7 @@ public class WebSocketService extends Service { return; } - connection.scheduleReconnect(TimeUnit.SECONDS.toMillis(5)); + connection.scheduleReconnect(15); } private void onBadRequest(String message) { @@ -133,7 +163,7 @@ public class WebSocketService extends Service { } private void notifyMissedNotifications() { - int messageId = lastReceivedMessage.get(); + long messageId = lastReceivedMessage.get(); if (messageId == NOT_LOADED) { return; } @@ -171,6 +201,7 @@ public class WebSocketService extends Service { if (lastReceivedMessage.get() < message.getId()) { lastReceivedMessage.set(message.getId()); } + broadcast(message); try (MessagingDatabase db = new MessagingDatabase(this)) { String registeredAppName = db.getAppFromId(message.getAppid()); @@ -186,7 +217,8 @@ public class WebSocketService extends Service { message.getTitle(), message.getMessage(), message.getPriority(), - message.getExtras()); + message.getExtras(), + message.getAppid()); } private void broadcast(Message message) { @@ -228,6 +260,16 @@ public class WebSocketService extends Service { private void showNotification( int id, String title, String message, long priority, Map extras) { + showNotification(id, title, message, priority, extras, -1L); + } + + private void showNotification( + long id, + String title, + String message, + long priority, + Map extras, + Long appid) { Intent intent; @@ -267,6 +309,7 @@ public class WebSocketService extends Service { .setDefaults(Notification.DEFAULT_ALL) .setWhen(System.currentTimeMillis()) .setSmallIcon(R.drawable.ic_gotify) + .setLargeIcon(picassoHandler.getIcon(appid)) .setTicker(getString(R.string.app_name) + " - " + title) .setGroup(NotificationSupport.Group.MESSAGES) .setContentTitle(title) @@ -279,7 +322,7 @@ public class WebSocketService extends Service { NotificationManager notificationManager = (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.notify(id, b.build()); + notificationManager.notify(Utils.longToInt(id), b.build()); } @RequiresApi(Build.VERSION_CODES.N) diff --git a/app/src/main/java/com/github/gotify/sharing/ShareActivity.java b/app/src/main/java/com/github/gotify/sharing/ShareActivity.java new file mode 100644 index 0000000..065a096 --- /dev/null +++ b/app/src/main/java/com/github/gotify/sharing/ShareActivity.java @@ -0,0 +1,153 @@ +package com.github.gotify.sharing; + +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.Toast; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; +import com.github.gotify.R; +import com.github.gotify.Settings; +import com.github.gotify.api.Api; +import com.github.gotify.api.ApiException; +import com.github.gotify.api.ClientFactory; +import com.github.gotify.client.ApiClient; +import com.github.gotify.client.api.MessageApi; +import com.github.gotify.client.model.Application; +import com.github.gotify.client.model.Message; +import com.github.gotify.log.Log; +import com.github.gotify.messages.provider.ApplicationHolder; +import java.util.ArrayList; +import java.util.List; + +import static com.github.gotify.Utils.first; + +public class ShareActivity extends AppCompatActivity { + private Settings settings; + private ApplicationHolder appsHolder; + + @BindView(R.id.title) + EditText edtTxtTitle; + + @BindView(R.id.content) + EditText edtTxtContent; + + @BindView(R.id.edtTxtPriority) + EditText edtTxtPriority; + + @BindView(R.id.appSpinner) + Spinner appSpinner; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_share); + ButterKnife.bind(this); + + Log.i("Entering " + getClass().getSimpleName()); + setSupportActionBar(findViewById(R.id.toolbar)); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowCustomEnabled(true); + } + settings = new Settings(this); + + Intent intent = getIntent(); + String type = intent.getType(); + if (Intent.ACTION_SEND.equals(intent.getAction()) && "text/plain".equals(type)) { + String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); + if (sharedText != null) { + edtTxtContent.setText(sharedText); + } + } + + ApiClient client = + ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token()); + appsHolder = new ApplicationHolder(this, client); + appsHolder.onUpdate(() -> populateSpinner(appsHolder.get())); + appsHolder.request(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + } + return super.onOptionsItemSelected(item); + } + + @OnClick(R.id.push_button) + public void pushMessage(View view) { + String titleText = edtTxtTitle.getText().toString(); + String contentText = edtTxtContent.getText().toString(); + String priority = edtTxtPriority.getText().toString(); + int appIndex = appSpinner.getSelectedItemPosition(); + + if (contentText.isEmpty()) { + Toast.makeText(this, "Content should not be empty.", Toast.LENGTH_LONG).show(); + return; + } else if (priority.isEmpty()) { + Toast.makeText(this, "Priority should be number.", Toast.LENGTH_LONG).show(); + return; + } + + Message message = new Message(); + if (!titleText.isEmpty()) { + message.setTitle(titleText); + } + message.setMessage(contentText); + message.setPriority(Long.parseLong(priority)); + new PushMessage(appsHolder.get().get(appIndex).getToken()).execute(message); + } + + private void populateSpinner(List apps) { + List appNameList = new ArrayList<>(); + for (Application app : apps) { + appNameList.add(app.getName()); + } + + ArrayAdapter adapter = + new ArrayAdapter<>( + this, android.R.layout.simple_spinner_dropdown_item, appNameList); + appSpinner.setAdapter(adapter); + } + + private class PushMessage extends AsyncTask { + private String token; + + public PushMessage(String token) { + this.token = token; + } + + @Override + protected String doInBackground(Message... messages) { + List apps = appsHolder.get(); + ApiClient pushClient = + ClientFactory.clientToken(settings.url(), settings.sslSettings(), token); + + try { + MessageApi messageApi = pushClient.createService(MessageApi.class); + Api.execute(messageApi.createMessage(first(messages))); + return "Pushed!"; + } catch (ApiException apiException) { + Log.e("Failed sending message", apiException); + return "Oops! Something went wrong..."; + } + } + + @Override + protected void onPostExecute(String message) { + Toast.makeText(ShareActivity.this, message, Toast.LENGTH_LONG).show(); + ShareActivity.this.finish(); + } + } +} diff --git a/app/src/main/res/drawable/ic_send.xml b/app/src/main/res/drawable/ic_send.xml new file mode 100644 index 0000000..ac34cb9 --- /dev/null +++ b/app/src/main/res/drawable/ic_send.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml index a087c86..af2a382 100644 --- a/app/src/main/res/drawable/ic_settings.xml +++ b/app/src/main/res/drawable/ic_settings.xml @@ -1,5 +1,7 @@ + android:viewportWidth="20" + android:width="24dp" + xmlns:android="http://schemas.android.com/apk/res/android"> diff --git a/app/src/main/res/layout/activity_share.xml b/app/src/main/res/layout/activity_share.xml new file mode 100644 index 0000000..1c20d6b --- /dev/null +++ b/app/src/main/res/layout/activity_share.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +