diff --git a/app/build.gradle b/app/build.gradle index 3539f8c..a94649b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,10 +1,16 @@ def versionMajor = 1 -def versionMinor = 7 +def versionMinor = 8 def versionPatch = 0 def versionBuild = 0 apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +apply plugin: 'kotlin-android-extensions' + +apply plugin: 'kotlin-kapt' + android { compileSdkVersion 29 buildToolsVersion '29.0.2' @@ -15,6 +21,17 @@ android { versionCode versionMajor * 1000000 + versionMinor * 10000 + versionPatch * 100 + versionBuild versionName "${versionMajor}.${versionMinor}.${versionPatch}" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + multiDexEnabled true + + javaCompileOptions { + annotationProcessorOptions { + arguments = [ + "room.schemaLocation" : "$projectDir/schemas".toString(), + "room.incremental" : "true", + "room.expandProjection": "true" + ] + } + } } buildTypes { release { @@ -22,31 +39,66 @@ android { } } compileOptions { - sourceCompatibility = '1.8' - targetCompatibility = '1.8' + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + } + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true } } dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test.ext:junit:1.1.1' + /* AndroidX */ + implementation 'androidx.appcompat:appcompat:1.2.0-alpha01' + implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4' + implementation 'androidx.core:core-ktx:1.2.0-rc01' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0' + implementation 'androidx.preference:preference-ktx:1.1.0' + implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha01' + implementation 'androidx.transition:transition:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + + /* Material Design */ + implementation 'com.google.android.material:material:1.2.0-alpha04' + + /* Firebase */ + implementation 'com.google.firebase:firebase-core:17.2.2' + implementation 'com.google.firebase:firebase-messaging:20.1.0' + implementation 'com.google.firebase:firebase-database:19.2.0' + + /* Koin: Dependency Injection */ + implementation 'org.koin:koin-android:2.0.1' + implementation 'org.koin:koin-android-scope:2.0.1' + implementation 'org.koin:koin-android-viewmodel:2.0.1' + testImplementation 'org.koin:koin-test:2.0.1' + androidTestImplementation 'org.koin:koin-test:2.0.1' + + /* Moshi: JSON parsing */ + implementation 'com.squareup.moshi:moshi-adapters:1.9.2' + implementation 'com.squareup.moshi:moshi-kotlin:1.9.2' + kapt 'com.squareup.moshi:moshi-kotlin-codegen:1.9.2' + + /* Room: SQLite persistence */ + implementation 'androidx.room:room-runtime:2.2.3' + kapt 'androidx.room:room-compiler:2.2.3' + implementation 'androidx.room:room-ktx:2.2.3' + testImplementation 'androidx.room:room-testing:2.2.3' + + /* Kotlin Coroutines */ + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3' + + /* JUnit */ + testImplementation 'junit:junit:4.13' - implementation 'androidx.appcompat:appcompat:1.1.0' - implementation 'com.google.android.material:material:1.1.0-alpha10' - implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.recyclerview:recyclerview:1.1.0-beta04' - implementation 'androidx.preference:preference-ktx:1.1.0' - implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta2' - implementation 'com.google.firebase:firebase-core:17.2.0' - implementation 'com.google.firebase:firebase-messaging:20.0.0' - implementation 'com.google.firebase:firebase-database:19.1.0' - implementation 'com.google.code.gson:gson:2.8.5' - implementation 'com.squareup.moshi:moshi:1.8.0' - implementation 'com.squareup.moshi:moshi-adapters:1.8.0' } apply plugin: 'com.google.gms.google-services' diff --git a/app/schemas/fr.smarquis.fcm.data.db.AppDatabase/1.json b/app/schemas/fr.smarquis.fcm.data.db.AppDatabase/1.json new file mode 100644 index 0000000..b01cf04 --- /dev/null +++ b/app/schemas/fr.smarquis.fcm.data.db.AppDatabase/1.json @@ -0,0 +1,94 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "564aa2fa1b2faa8cd7b3abf4a25296e2", + "entities": [ + { + "tableName": "Message", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `from` TEXT, `to` TEXT, `data` TEXT NOT NULL, `collapseKey` TEXT, `messageType` TEXT, `sentTime` INTEGER NOT NULL, `ttl` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `originalPriority` INTEGER NOT NULL, `payload` TEXT, PRIMARY KEY(`messageId`))", + "fields": [ + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "from", + "columnName": "from", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "collapseKey", + "columnName": "collapseKey", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sentTime", + "columnName": "sentTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ttl", + "columnName": "ttl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "originalPriority", + "columnName": "originalPriority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "messageId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '564aa2fa1b2faa8cd7b3abf4a25296e2')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/fr/smarquis/fcm/ExampleInstrumentedTest.java b/app/src/androidTest/java/fr/smarquis/fcm/ExampleInstrumentedTest.kt similarity index 53% rename from app/src/androidTest/java/fr/smarquis/fcm/ExampleInstrumentedTest.java rename to app/src/androidTest/java/fr/smarquis/fcm/ExampleInstrumentedTest.kt index ec053ac..eed6764 100644 --- a/app/src/androidTest/java/fr/smarquis/fcm/ExampleInstrumentedTest.java +++ b/app/src/androidTest/java/fr/smarquis/fcm/ExampleInstrumentedTest.kt @@ -13,31 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package fr.smarquis.fcm -package fr.smarquis.fcm; - -import android.content.Context; -import androidx.test.InstrumentationRegistry; -import androidx.test.runner.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.*; +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith /** * Instrumentation test, which will execute on an Android device. * - * @see Testing documentation + * @see [Testing documentation](http://d.android.com/tools/testing) */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { @Test - public void useAppContext() { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getTargetContext(); - - assertEquals("fr.smarquis.fcm", appContext.getPackageName()); + fun useAppContext() { // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + Assert.assertEquals("fr.smarquis.fcm", appContext.packageName) } -} +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f0c52cc..417bb82 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,15 +23,17 @@ + @@ -43,15 +45,17 @@ + + \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/App.kt b/app/src/main/java/fr/smarquis/fcm/App.kt new file mode 100644 index 0000000..ea8ce23 --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/App.kt @@ -0,0 +1,22 @@ +package fr.smarquis.fcm + +import androidx.multidex.MultiDexApplication +import fr.smarquis.fcm.di.database +import fr.smarquis.fcm.di.json +import fr.smarquis.fcm.di.main +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin + + +@Suppress("unused") +class App : MultiDexApplication() { + + override fun onCreate() { + super.onCreate() + startKoin { + androidContext(this@App) + modules(listOf(main, json, database)) + } + } + +} diff --git a/app/src/main/java/fr/smarquis/fcm/FcmService.java b/app/src/main/java/fr/smarquis/fcm/FcmService.java deleted file mode 100644 index 15cc994..0000000 --- a/app/src/main/java/fr/smarquis/fcm/FcmService.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2017 Simon Marquis - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.smarquis.fcm; - -import android.content.Context; -import android.os.Handler; -import android.os.Looper; - -import androidx.annotation.UiThread; -import androidx.annotation.WorkerThread; - -import com.google.firebase.messaging.FirebaseMessagingService; -import com.google.firebase.messaging.RemoteMessage; - -import fr.smarquis.fcm.payloads.Link; -import fr.smarquis.fcm.payloads.Payload; -import fr.smarquis.fcm.payloads.Text; - - -public class FcmService extends FirebaseMessagingService { - - @Override - public void onNewToken(String token) { - Token.broadcast(this, token); - } - - @Override - @WorkerThread - public void onMessageReceived(RemoteMessage remoteMessage) { - super.onMessageReceived(remoteMessage); - Message message = Message.from(remoteMessage); - boolean silent = Boolean.valueOf(remoteMessage.getData().get("hide")); - new Handler(Looper.getMainLooper()).post(() -> notifyAndExecute(message, silent, this)); - } - - @UiThread - private void notifyAndExecute(Message message, boolean silent, Context context) { - if (!silent) { - Notifications.show(context, message); - } - Messages.instance(context).add(message); - Payload payload = message.payload(); - if (payload instanceof Link) { - Link link = (Link) payload; - if (link.open()) { - startActivity(link.intent()); - } - } else if (payload instanceof Text) { - Text text = (Text) payload; - if (text.clipboard()) { - text.copyToClipboard(this); - } - } - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/MainActivity.java b/app/src/main/java/fr/smarquis/fcm/MainActivity.java deleted file mode 100644 index 82acaa6..0000000 --- a/app/src/main/java/fr/smarquis/fcm/MainActivity.java +++ /dev/null @@ -1,288 +0,0 @@ -/* - * Copyright 2017 Simon Marquis - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.smarquis.fcm; - -import android.annotation.SuppressLint; -import android.content.BroadcastReceiver; -import android.content.Intent; -import android.content.res.Resources; -import android.os.AsyncTask; -import android.os.Bundle; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.EditText; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.gms.tasks.Task; -import com.google.android.material.snackbar.Snackbar; -import com.google.firebase.iid.FirebaseInstanceId; -import com.google.firebase.iid.InstanceIdResult; -import com.google.firebase.messaging.FirebaseMessaging; - -import java.io.IOException; -import java.util.List; -import java.util.concurrent.TimeUnit; -import java.util.regex.Pattern; - -import fr.smarquis.fcm.payloads.Payload; - -import static android.content.DialogInterface.BUTTON_NEGATIVE; -import static android.content.DialogInterface.BUTTON_POSITIVE; -import static android.widget.Toast.LENGTH_LONG; - -public class MainActivity extends AppCompatActivity implements Messages.Listener { - - private PresenceEventListener presence; - - private MessageAdapter adapter; - - private final BroadcastReceiver tokenReceiver = Token.create(this::applyToken); - - private final BroadcastReceiver presenceReceiver = Presence.create(presence -> supportInvalidateOptionsMenu()); - - private View emptyView; - - private RecyclerView recyclerView; - - private Messages messages; - - @Nullable - private String token; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - presence = PresenceEventListener.instance(this); - messages = Messages.instance(this); - initRecyclerView(); - registerReceivers(); - } - - @Override - protected void onStart() { - super.onStart(); - presence.register(this); - } - - @Override - protected void onStop() { - super.onStop(); - presence.unregister(this); - } - - @Override - protected void onResume() { - super.onResume(); - Notifications.removeAll(this); - fetchToken(); - } - - - @Override - protected void onDestroy() { - super.onDestroy(); - unregisterReceivers(); - } - - private void fetchToken() { - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setSubtitle(R.string.fetching); - } - Task instanceId = FirebaseInstanceId.getInstance().getInstanceId(); - instanceId.addOnSuccessListener(this, instanceIdResult -> applyToken(instanceIdResult.getToken())); - } - - private void applyToken(String token) { - this.token = token; - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setSubtitle(token); - } - supportInvalidateOptionsMenu(); - } - - private void registerReceivers() { - Token.register(this, tokenReceiver); - Presence.register(this, presenceReceiver); - messages.register(this); - } - - private void unregisterReceivers() { - Token.unregister(this, tokenReceiver); - Presence.unregister(this, presenceReceiver); - messages.unregister(this); - } - - private void initRecyclerView() { - emptyView = findViewById(R.id.empty_view); - recyclerView = findViewById(R.id.recycler_view); - Resources resources = getResources(); - int horizontal = resources.getDimensionPixelSize(R.dimen.unit_4); - int vertical = resources.getDimensionPixelSize(R.dimen.unit_1); - recyclerView.addItemDecoration(new SpacingItemDecoration(horizontal, vertical)); - recyclerView.setHasFixedSize(false); - RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(this); - recyclerView.setLayoutManager(layoutManager); - adapter = new MessageAdapter(messages.get()); - adapter.setHasStableIds(true); - recyclerView.setAdapter(adapter); - ItemTouchHelper.SimpleCallback simpleItemTouchCallback = new ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) { - - @Override - public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { - return false; - } - - @Override - public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) { - final int position = viewHolder.getAdapterPosition(); - Message removed = adapter.removeItemAtPosition(position); - messages.remove(removed); - onAdapterCountMightHaveChanged(); - String message = getString(R.string.snackbar_item_deleted, 1); - Snackbar snackbar = Snackbar.make(recyclerView, message, Snackbar.LENGTH_LONG); - snackbar.setAction(R.string.snackbar_item_undo, v -> messages.add(removed)).show(); - } - }; - ItemTouchHelper itemTouchHelper = new ItemTouchHelper(simpleItemTouchCallback); - itemTouchHelper.attachToRecyclerView(recyclerView); - onAdapterCountMightHaveChanged(); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.menu_main, menu); - return true; - } - - @Override - public boolean onPrepareOptionsMenu(Menu menu) { - menu.findItem(R.id.action_presence).setIcon(presence.isConnected() ? android.R.drawable.presence_online : android.R.drawable.presence_invisible); - menu.findItem(R.id.action_share_token).setVisible(!TextUtils.isEmpty(token)); - menu.findItem(R.id.action_delete_all).setVisible(adapter != null && adapter.getItemCount() > 0); - return super.onPrepareOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.action_share_token: - if (token != null) { - Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - intent.putExtra(Intent.EXTRA_TEXT, token); - startActivity(Intent.createChooser(intent, getString(R.string.menu_share_token))); - } - return true; - case R.id.action_invalidate_token: - AsyncTask.execute(() -> { - try { - FirebaseInstanceId.getInstance().deleteInstanceId(); - runOnUiThread(() -> { - presence.reset(); - finish(); - startActivity(getIntent()); - }); - } catch (IOException e) { - e.printStackTrace(); - } - }); - break; - case R.id.action_topics: - // Extracted from com.google.firebase.messaging.FirebaseMessaging - Pattern pattern = Pattern.compile("[a-zA-Z0-9-_.~%]{1,900}"); - @SuppressLint("InflateParams") View view = LayoutInflater.from(this).inflate(R.layout.topics_dialog, null, false); - EditText input = view.findViewById(R.id.input); - AlertDialog dialog = new AlertDialog.Builder(this) - .setTitle(R.string.menu_topics) - .setView(view) - .setPositiveButton(R.string.topics_subscribe, (d, which) -> { - String topic = input.getText().toString(); - FirebaseMessaging.getInstance().subscribeToTopic(topic) - .addOnSuccessListener(this, success -> Toast.makeText(this, getString(R.string.topics_subscribed, topic), LENGTH_LONG).show()) - .addOnFailureListener(this, error -> Toast.makeText(this, Util.printStackTrace(error), LENGTH_LONG).show()); - }) - .setNegativeButton(R.string.topics_unsubscribe, (d, which) -> { - String topic = input.getText().toString(); - FirebaseMessaging.getInstance().unsubscribeFromTopic(topic) - .addOnSuccessListener(this, success -> Toast.makeText(this, getString(R.string.topics_unsubscribed, topic), LENGTH_LONG).show()) - .addOnFailureListener(this, error -> Toast.makeText(this, Util.printStackTrace(error), LENGTH_LONG).show()); - }).show(); - input.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - - @Override - public void afterTextChanged(Editable s) { - boolean matches = pattern.matcher(s).matches(); - dialog.getButton(BUTTON_POSITIVE).setEnabled(matches); - dialog.getButton(BUTTON_NEGATIVE).setEnabled(matches); - } - }); - // Trigger afterTextChanged() - input.setText(null); - return true; - case R.id.action_delete_all: - Notifications.removeAll(this); - List> removedMessages = messages.get(); - messages.clear(); - adapter.clear(); - String message = getString(R.string.snackbar_item_deleted, removedMessages.size()); - Snackbar.make(recyclerView, message, (int) TimeUnit.SECONDS.toMillis(10)) - .setAction(R.string.snackbar_item_undo, v -> messages.add(removedMessages)) - .show(); - onAdapterCountMightHaveChanged(); - return true; - } - - return super.onOptionsItemSelected(item); - } - - @Override - public void onNewMessage(@NonNull Message message) { - int index = adapter.add(message); - recyclerView.smoothScrollToPosition(index); - onAdapterCountMightHaveChanged(); - } - - private void onAdapterCountMightHaveChanged() { - int count = adapter != null ? adapter.getItemCount() : 0; - emptyView.setVisibility(count > 0 ? View.INVISIBLE : View.VISIBLE); - supportInvalidateOptionsMenu(); - } -} diff --git a/app/src/main/java/fr/smarquis/fcm/Message.java b/app/src/main/java/fr/smarquis/fcm/Message.java deleted file mode 100644 index c0b515d..0000000 --- a/app/src/main/java/fr/smarquis/fcm/Message.java +++ /dev/null @@ -1,105 +0,0 @@ -package fr.smarquis.fcm; - -import androidx.annotation.NonNull; - -import com.google.firebase.messaging.RemoteMessage; -import com.squareup.moshi.Json; - -import java.util.Map; - -import fr.smarquis.fcm.payloads.Payload; -import fr.smarquis.fcm.payloads.Payloads; - -public class Message

implements Comparable> { - - @Json(name = "from") - private final String from; - @Json(name = "to") - private final String to; - @Json(name = "data") - private final Map data; - @Json(name = "collapseKey") - private final String collapseKey; - @Json(name = "messageId") - private final String messageId; - @Json(name = "messageType") - private final String messageType; - @Json(name = "sentTime") - private final long sentTime; - @Json(name = "ttl") - private final int ttl; - @Json(name = "priority") - private final int priority; - @Json(name = "originalPriority") - private final int originalPriority; - - @Json(name = "payload") - private final Payload payload; - - private Message(String from, String to, Map data, String collapseKey, String messageId, String messageType, long sentTime, int ttl, int priority, int originalPriority, P payload) { - this.from = from; - this.to = to; - this.data = data; - this.collapseKey = collapseKey; - this.messageId = messageId; - this.messageType = messageType; - this.sentTime = sentTime; - this.ttl = ttl; - this.priority = priority; - this.originalPriority = originalPriority; - this.payload = payload; - } - - @NonNull - static Message from(@NonNull RemoteMessage message) { - return new Message<>( - message.getFrom(), - message.getTo(), - message.getData(), - message.getCollapseKey(), - message.getMessageId(), - message.getMessageType(), - message.getSentTime(), - message.getTtl(), - message.getOriginalPriority(), - message.getPriority(), - Payloads.extract(message)); - } - - public String id() { - return messageId; - } - - public Map data() { - return data; - } - - @NonNull - public Payload payload() { - return payload; - } - - public long sentTime() { - return sentTime; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - Message message = (Message) o; - - return messageId != null ? messageId.equals(message.messageId) : message.messageId == null; - } - - @Override - public int hashCode() { - return messageId != null ? messageId.hashCode() : 0; - } - - @Override - public int compareTo(@NonNull Message

other) { - return (other.sentTime() < sentTime()) ? -1 : ((other.sentTime() == sentTime()) ? id().compareTo(other.id()) : 1); - } -} diff --git a/app/src/main/java/fr/smarquis/fcm/MessageAdapter.java b/app/src/main/java/fr/smarquis/fcm/MessageAdapter.java deleted file mode 100644 index 9f16d92..0000000 --- a/app/src/main/java/fr/smarquis/fcm/MessageAdapter.java +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2017 Simon Marquis - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.smarquis.fcm; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import fr.smarquis.fcm.payloads.Payload; -import fr.smarquis.fcm.payloads.ViewHolder; - -class MessageAdapter extends RecyclerView.Adapter { - - private static final Map stableIds = new HashMap<>(); - - private static final Map selection = new HashMap<>(); - - @NonNull - private final List> messages = new ArrayList<>(); - - MessageAdapter(@NonNull List> messages) { - this.messages.addAll(messages); - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_payload, parent, false); - return new ViewHolder(view, this::onClick); - } - - private void onClick(@Nullable Message message) { - if (message == null) { - return; - } - Boolean value = selection.get(message); - selection.put(message, value != null ? Boolean.valueOf(!value) : Boolean.TRUE); - notifyItemChanged(messages.indexOf(message)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) { - Message message = messages.get(position); - Boolean selected = selection.get(message); - viewHolder.onBind(message, selected != null ? selected : Boolean.FALSE); - } - - @Override - public void onViewRecycled(@NonNull ViewHolder holder) { - holder.onUnbind(); - } - - @Override - public int getItemCount() { - return messages.size(); - } - - @Override - public long getItemId(int position) { - String key = messages.get(position).id(); - Long value = stableIds.get(key); - if (value == null) { - value = (long) stableIds.size(); - stableIds.put(key, value); - } - return value; - } - - Message removeItemAtPosition(int position) { - Message message = messages.remove(position); - notifyItemRemoved(position); - return message; - } - - void clear() { - messages.clear(); - notifyDataSetChanged(); - } - - int add(@NonNull Message message) { - messages.add(0, message); - Collections.sort(messages); - int indexOf = messages.indexOf(message); - notifyItemInserted(indexOf); - return indexOf; - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/Messages.java b/app/src/main/java/fr/smarquis/fcm/Messages.java deleted file mode 100644 index 3c4f26c..0000000 --- a/app/src/main/java/fr/smarquis/fcm/Messages.java +++ /dev/null @@ -1,146 +0,0 @@ -package fr.smarquis.fcm; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.SharedPreferences; -import android.text.TextUtils; - -import androidx.annotation.NonNull; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; - -import java.io.IOException; -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import fr.smarquis.fcm.payloads.Payload; -import fr.smarquis.fcm.payloads.Payloads; - -public final class Messages { - - private static final String SHARED_PREFERENCES_NAME = "messages"; - - private static final String KEY = "data"; - - private static final Moshi MOSHI = new Moshi.Builder().add(Payloads.JSON_ADAPTER).build(); - - @SuppressLint("StaticFieldLeak") - private static volatile Messages instance = null; - - private final SharedPreferences sp; - - @NonNull - private final List> messages = new ArrayList<>(); - - private static final Type TYPE = Types.newParameterizedType(List.class, Message.class); - - private static final JsonAdapter>> ADAPTER = MOSHI.adapter(TYPE); - - private final List listeners = Collections.synchronizedList(new ArrayList<>()); - - interface Listener { - void onNewMessage(@NonNull Message message); - } - - public static Moshi moshi() { - return MOSHI; - } - - @NonNull - static Messages instance(@NonNull Context context) { - if (instance == null) { - synchronized (Messages.class) { - if (instance == null) { - instance = new Messages(context); - } - } - } - return instance; - } - - private Messages(@NonNull Context context) { - sp = context.getApplicationContext().getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE); - String value = sp.getString(KEY, null); - if (!TextUtils.isEmpty(value)) { - try { - List> list = ADAPTER.fromJson(value); - if (list != null) { - messages.addAll(list); - Collections.sort(messages); - } - } catch (IOException e) { - e.printStackTrace(); - sp.edit().remove(KEY).apply(); - } - } - } - - void register(@NonNull Listener listener) { - synchronized (listeners) { - listeners.add(listener); - } - } - - void unregister(@NonNull Listener listener) { - synchronized (listeners) { - listeners.remove(listener); - } - } - - @NonNull - List> get() { - return new ArrayList<>(messages); - } - - void add(@NonNull Message message) { - if (messages.contains(message)) { - return; - } - messages.add(message); - Collections.sort(messages); - persist(); - notifyListeners(message); - } - - void add(@NonNull List> messages) { - this.messages.addAll(messages); - Collections.sort(this.messages); - persist(); - for (int i = messages.size() - 1; i >= 0; i--) { - notifyListeners(messages.get(i)); - } - } - - private void notifyListeners(@NonNull Message message) { - synchronized (listeners) { - for (Listener listener : listeners) { - listener.onNewMessage(message); - } - } - } - - void remove(@NonNull Message message) { - if (!messages.contains(message)) { - return; - } - messages.remove(message); - persist(); - } - - void clear() { - if (messages.isEmpty()) { - return; - } - messages.clear(); - persist(); - } - - private void persist() { - sp.edit().putString(KEY, ADAPTER.toJson(messages)).apply(); - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/Notifications.java b/app/src/main/java/fr/smarquis/fcm/Notifications.java deleted file mode 100644 index 700e509..0000000 --- a/app/src/main/java/fr/smarquis/fcm/Notifications.java +++ /dev/null @@ -1,64 +0,0 @@ -package fr.smarquis.fcm; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; -import androidx.core.app.NotificationCompat; -import androidx.core.content.ContextCompat; - -import fr.smarquis.fcm.payloads.Payload; - -final class Notifications { - - private Notifications() { - } - - private static NotificationManager getNotificationManager(Context context) { - return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - } - - @RequiresApi(api = Build.VERSION_CODES.O) - private static void createNotificationChannel(Context context) { - String id = context.getString(R.string.notification_channel_id); - CharSequence name = context.getString(R.string.notification_channel_name); - int importance = NotificationManager.IMPORTANCE_HIGH; - NotificationChannel channel = new NotificationChannel(id, name, importance); - channel.enableLights(true); - channel.enableVibration(true); - channel.setShowBadge(true); - getNotificationManager(context).createNotificationChannel(channel); - } - - @NonNull - private static NotificationCompat.Builder getNotificationBuilder(@NonNull Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel(context); - } - return new NotificationCompat.Builder(context, context.getString(R.string.notification_channel_id)) - .setColor(ContextCompat.getColor(context, R.color.colorPrimary)) - .setContentIntent(PendingIntent.getActivity(context, 0, new Intent(context, MainActivity.class), 0)) - .setLocalOnly(true) - .setAutoCancel(true) - .setDefaults(NotificationCompat.DEFAULT_ALL) - .setPriority(NotificationCompat.PRIORITY_MAX) - .setCategory(NotificationCompat.CATEGORY_MESSAGE); - } - - static void show(@NonNull Context context, @NonNull Message message) { - Payload payload = message.payload(); - Notification notification = payload.configure(Notifications.getNotificationBuilder(context).setSmallIcon(payload.icon())).build(); - getNotificationManager(context).notify(message.id(), payload.notificationId(), notification); - } - - static void removeAll(Context context) { - getNotificationManager(context).cancelAll(); - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/Presence.java b/app/src/main/java/fr/smarquis/fcm/Presence.java deleted file mode 100644 index 114f687..0000000 --- a/app/src/main/java/fr/smarquis/fcm/Presence.java +++ /dev/null @@ -1,56 +0,0 @@ -package fr.smarquis.fcm; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; - -import androidx.annotation.Keep; -import androidx.annotation.NonNull; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -@Keep -final class Presence { - - private static final String INTENT_ACTION = Presence.class.getName() + ".Update"; - - private static final IntentFilter INTENT_FILTER = new IntentFilter(INTENT_ACTION); - - private static final Intent INTENT = new Intent(INTENT_ACTION); - - private static final String EXTRA_KEY = "presence"; - - - interface Handler { - void handle(boolean presence); - } - - private static LocalBroadcastManager manager(@NonNull Context context) { - return LocalBroadcastManager.getInstance(context); - } - - static void broadcast(@NonNull Context context, boolean presence) { - Intent intent = new Intent(INTENT); - intent.putExtra(EXTRA_KEY, presence); - manager(context).sendBroadcast(new Intent(INTENT)); - } - - @NonNull - static BroadcastReceiver create(Handler handler) { - return new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - handler.handle(intent.getBooleanExtra(EXTRA_KEY, false)); - } - }; - } - - static void register(@NonNull Context context, @NonNull BroadcastReceiver receiver) { - manager(context).registerReceiver(receiver, INTENT_FILTER); - } - - static void unregister(@NonNull Context context, @NonNull BroadcastReceiver receiver) { - manager(context).unregisterReceiver(receiver); - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/PresenceEventListener.java b/app/src/main/java/fr/smarquis/fcm/PresenceEventListener.java deleted file mode 100644 index 3d175c3..0000000 --- a/app/src/main/java/fr/smarquis/fcm/PresenceEventListener.java +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2017 Simon Marquis - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.smarquis.fcm; - -import android.annotation.SuppressLint; -import android.content.Context; - -import androidx.annotation.NonNull; - -import com.google.android.gms.tasks.Task; -import com.google.firebase.database.DataSnapshot; -import com.google.firebase.database.DatabaseError; -import com.google.firebase.database.DatabaseReference; -import com.google.firebase.database.FirebaseDatabase; -import com.google.firebase.database.ServerValue; -import com.google.firebase.database.ValueEventListener; -import com.google.firebase.iid.FirebaseInstanceId; -import com.google.firebase.iid.InstanceIdResult; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Locale; -import java.util.Set; - -import static android.os.Build.MANUFACTURER; -import static android.os.Build.MODEL; - -class PresenceEventListener implements ValueEventListener { - - @SuppressLint("StaticFieldLeak") - private static volatile PresenceEventListener instance; - - private final Context context; - - private final DatabaseReference presenceRef; - - private final DatabaseReference connectionRef; - - private boolean isConnected = false; - - @NonNull - static PresenceEventListener instance(@NonNull Context context) { - if (instance == null) { - synchronized (Messages.class) { - if (instance == null) { - instance = new PresenceEventListener(context); - } - } - } - return instance; - } - - private PresenceEventListener(Context context) { - this.context = context.getApplicationContext(); - String uuid = Uuid.get(context); - FirebaseDatabase database = FirebaseDatabase.getInstance(); - presenceRef = database.getReference(".info/connected"); - connectionRef = database.getReference("devices/" + uuid); - connectionRef.onDisconnect().removeValue(); - } - - boolean isConnected() { - return isConnected; - } - - @Override - public void onCancelled(@NonNull DatabaseError error) { - if (isConnected) { - isConnected = false; - Presence.broadcast(context, false); - } - } - - @Override - public void onDataChange(@NonNull DataSnapshot snapshot) { - boolean connected = Boolean.TRUE.equals(snapshot.getValue(Boolean.class)); - if (connected == isConnected) { - return; - } - isConnected = connected; - Presence.broadcast(context, isConnected); - if (connected) { - Task instanceId = FirebaseInstanceId.getInstance().getInstanceId(); - instanceId.addOnSuccessListener(instanceIdResult -> setConnectionReference(instanceIdResult.getToken())); - } - } - - private void setConnectionReference(String token) { - HashMap result = new HashMap<>(3); - Locale locale = Locale.getDefault(); - final String displayName = MODEL.toLowerCase(locale).startsWith(MANUFACTURER.toLowerCase(locale)) ? MODEL : MANUFACTURER.toUpperCase(locale) + " " + MODEL; - result.put("name", displayName); - result.put("token", token); - result.put("timestamp", ServerValue.TIMESTAMP); - connectionRef.setValue(result); - } - - @NonNull - private final Set listeners = new HashSet<>(); - - void register(Object listener) { - synchronized (listeners) { - boolean empty = listeners.isEmpty(); - listeners.add(listener); - if (empty) { - goOnline(); - } - } - } - - void unregister(Object listener) { - synchronized (listeners) { - listeners.remove(listener); - if (listeners.isEmpty()) { - goOffline(); - } - } - } - - void reset() { - goOffline(); - goOnline(); - } - - private void goOffline() { - connectionRef.removeValue(); - DatabaseReference.goOffline(); - presenceRef.removeEventListener(this); - isConnected = false; - } - - private void goOnline() { - presenceRef.addValueEventListener(this); - DatabaseReference.goOnline(); - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/SpacingItemDecoration.java b/app/src/main/java/fr/smarquis/fcm/SpacingItemDecoration.java deleted file mode 100644 index 67bb7b5..0000000 --- a/app/src/main/java/fr/smarquis/fcm/SpacingItemDecoration.java +++ /dev/null @@ -1,44 +0,0 @@ -package fr.smarquis.fcm; - -import android.graphics.Rect; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Px; -import androidx.recyclerview.widget.RecyclerView; - -import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; -import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; - -class SpacingItemDecoration extends RecyclerView.ItemDecoration { - - private final int horizontal; - private final int vertical; - - SpacingItemDecoration(@Px int horizontal, @Px int vertical) { - this.horizontal = horizontal; - this.vertical = vertical; - } - - @Override - public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { - int position = parent.getChildAdapterPosition(view); - if (position == RecyclerView.NO_POSITION) { - return; - } - RecyclerView.Adapter adapter = parent.getAdapter(); - boolean isLastItem = position != (adapter != null ? adapter.getItemCount() : 0) - 1; - outRect.top = vertical; - outRect.bottom = isLastItem ? 0 : vertical; - int viewWidth = view.getLayoutParams().width; - switch (viewWidth) { - case MATCH_PARENT: - case WRAP_CONTENT: - outRect.left = outRect.right = horizontal; - break; - default: - outRect.left = outRect.right = Math.max(horizontal, (parent.getWidth() - viewWidth) / 2); - break; - } - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/TimeAgoTextView.java b/app/src/main/java/fr/smarquis/fcm/TimeAgoTextView.java deleted file mode 100644 index 262008f..0000000 --- a/app/src/main/java/fr/smarquis/fcm/TimeAgoTextView.java +++ /dev/null @@ -1,116 +0,0 @@ -package fr.smarquis.fcm; - -import android.content.Context; -import android.os.CountDownTimer; -import android.text.format.DateUtils; -import android.util.AttributeSet; - -import androidx.annotation.Nullable; -import androidx.appcompat.widget.AppCompatTextView; - -import java.util.concurrent.TimeUnit; - -public class TimeAgoTextView extends AppCompatTextView { - - public static final long NO_TIMESTAMP = 0; - - private static final long SECOND_TO_MILLIS = TimeUnit.SECONDS.toMillis(1); - - private static final long MINUTE_TO_MILLIS = TimeUnit.MINUTES.toMillis(1); - - private static final long HOUR_TO_MILLIS = TimeUnit.HOURS.toMillis(1); - - private long timestamp; - - @Nullable - private CountDownTimer countDownTimer; - - private boolean isAttachedToWindow = false; - - public TimeAgoTextView(Context context) { - super(context); - } - - public TimeAgoTextView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - public TimeAgoTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public void setTimestamp(long timestamp) { - this.timestamp = timestamp; - renderTimestamp(); - restartCountDown(); - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - isAttachedToWindow = true; - startCountDown(); - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - isAttachedToWindow = false; - stopCountDown(); - } - - private void startCountDown() { - if (isAttachedToWindow && timestamp > NO_TIMESTAMP) { - if (countDownTimer == null) { - final long now = System.currentTimeMillis(); - final long diff = Math.abs(now - timestamp); - - long millisInFuture; - long countDownInterval; - if (diff < MINUTE_TO_MILLIS) { - millisInFuture = MINUTE_TO_MILLIS; - countDownInterval = SECOND_TO_MILLIS; - } else if (diff < HOUR_TO_MILLIS) { - millisInFuture = HOUR_TO_MILLIS; - countDownInterval = MINUTE_TO_MILLIS; - } else { - // ignore - return; - } - countDownTimer = new CountDownTimer(millisInFuture, countDownInterval) { - - public void onTick(long millisUntilFinished) { - renderTimestamp(); - } - - public void onFinish() { - restartCountDown(); - } - }.start(); - } - } - } - - private void restartCountDown() { - stopCountDown(); - startCountDown(); - } - - private void stopCountDown() { - if (countDownTimer != null) { - countDownTimer.cancel(); - countDownTimer = null; - } - } - - private void renderTimestamp() { - if (timestamp > NO_TIMESTAMP) { - final long now = System.currentTimeMillis(); - setText(String.valueOf(DateUtils.getRelativeTimeSpanString(timestamp, now, DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE))); - } else { - setText(null); - } - } - - -} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/Token.java b/app/src/main/java/fr/smarquis/fcm/Token.java deleted file mode 100644 index 512bfd7..0000000 --- a/app/src/main/java/fr/smarquis/fcm/Token.java +++ /dev/null @@ -1,55 +0,0 @@ -package fr.smarquis.fcm; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; - -import androidx.annotation.Keep; -import androidx.annotation.NonNull; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -@Keep -final class Token { - - private static final String INTENT_ACTION = Token.class.getName() + "Update"; - - private static final Intent INTENT = new Intent(INTENT_ACTION); - - private static final IntentFilter INTENT_FILTER = new IntentFilter(INTENT_ACTION); - - private static final String EXTRA_KEY = "token"; - - interface Handler { - void handle(String token); - } - - private static LocalBroadcastManager manager(@NonNull Context context) { - return LocalBroadcastManager.getInstance(context); - } - - static void broadcast(@NonNull Context context, @NonNull String token) { - Intent intent = new Intent(INTENT); - intent.putExtra(EXTRA_KEY, token); - manager(context).sendBroadcast(intent); - } - - @NonNull - static BroadcastReceiver create(Handler handler) { - return new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - handler.handle(intent.getStringExtra(EXTRA_KEY)); - } - }; - } - - static void register(@NonNull Context context, @NonNull BroadcastReceiver receiver) { - manager(context).registerReceiver(receiver, INTENT_FILTER); - } - - static void unregister(@NonNull Context context, @NonNull BroadcastReceiver receiver) { - manager(context).unregisterReceiver(receiver); - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/Truss.java b/app/src/main/java/fr/smarquis/fcm/Truss.java deleted file mode 100644 index a2e6293..0000000 --- a/app/src/main/java/fr/smarquis/fcm/Truss.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2017 Simon Marquis - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.smarquis.fcm; - - -import android.text.SpannableStringBuilder; - -import java.util.ArrayDeque; -import java.util.Deque; - -import static android.text.Spanned.SPAN_INCLUSIVE_EXCLUSIVE; - -/** - * A {@link SpannableStringBuilder} wrapper whose API doesn't make me want to stab my eyes out. - * - * @see Source - */ -@SuppressWarnings("ALL") -public class Truss { - - private final SpannableStringBuilder builder; - - private final Deque stack; - - public Truss() { - builder = new SpannableStringBuilder(); - stack = new ArrayDeque<>(); - } - - public Truss append(String string) { - if (string != null) { - builder.append(string); - } - return this; - } - - public Truss append(CharSequence charSequence) { - if (charSequence != null) { - builder.append(charSequence); - } - return this; - } - - public Truss append(char c) { - builder.append(c); - return this; - } - - public Truss append(int number) { - builder.append(String.valueOf(number)); - return this; - } - - /** - * Starts {@code span} at the current position in the builder. - */ - public Truss pushSpan(Object span) { - stack.addLast(new Span(builder.length(), span)); - return this; - } - - /** - * End the most recently pushed span at the current position in the builder. - */ - public Truss popSpan() { - Span span = stack.removeLast(); - builder.setSpan(span.span, span.start, builder.length(), SPAN_INCLUSIVE_EXCLUSIVE); - return this; - } - - /** - * Create the final {@link CharSequence}, popping any remaining spans. - */ - public CharSequence build() { - while (!stack.isEmpty()) { - popSpan(); - } - return builder; - } - - private static final class Span { - - final int start; - - final Object span; - - public Span(int start, Object span) { - this.start = start; - this.span = span; - } - } -} diff --git a/app/src/main/java/fr/smarquis/fcm/Util.java b/app/src/main/java/fr/smarquis/fcm/Util.java deleted file mode 100644 index d8edadd..0000000 --- a/app/src/main/java/fr/smarquis/fcm/Util.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2017 Simon Marquis - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.smarquis.fcm; - -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.Intent; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.PrintWriter; -import java.io.StringWriter; - -public class Util { - - static String printStackTrace(@NonNull Throwable exception) { - StringWriter trace = new StringWriter(); - exception.printStackTrace(new PrintWriter(trace)); - return trace.toString(); - } - - public static void copyToClipboard(@NonNull Context context, @Nullable CharSequence text) { - ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText(null, text); - if (clipboard != null) { - clipboard.setPrimaryClip(clip); - Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show(); - } - } - - public static void safeStartActivity(@NonNull Context context, @Nullable Intent intent) { - try { - context.startActivity(intent); - } catch (Exception e) { - Toast.makeText(context, Util.printStackTrace(e), Toast.LENGTH_LONG).show(); - } - } -} diff --git a/app/src/main/java/fr/smarquis/fcm/Uuid.java b/app/src/main/java/fr/smarquis/fcm/Uuid.java deleted file mode 100644 index 78573d9..0000000 --- a/app/src/main/java/fr/smarquis/fcm/Uuid.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2017 Simon Marquis - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.smarquis.fcm; - -import android.content.Context; -import android.content.SharedPreferences; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.preference.PreferenceManager; - -import java.util.UUID; - -final class Uuid { - - private static final String KEY = "uuid"; - - /** - * @return UUID.randomUUID() or a previously generated UUID, stored in SharedPreferences - */ - static String get(@NonNull Context context) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); - if (prefs.contains(KEY)) { - final String value = prefs.getString(KEY, null); - if (!TextUtils.isEmpty(value)) { - return value; - } - } - final String uuid = UUID.randomUUID().toString(); - prefs.edit().putString(KEY, uuid).apply(); - return uuid; - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/data/db/AppDatabase.kt b/app/src/main/java/fr/smarquis/fcm/data/db/AppDatabase.kt new file mode 100644 index 0000000..b668ccc --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/data/db/AppDatabase.kt @@ -0,0 +1,55 @@ +package fr.smarquis.fcm.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import androidx.room.TypeConverters +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import fr.smarquis.fcm.data.db.AppDatabase.MapConverter +import fr.smarquis.fcm.data.db.AppDatabase.PayloadConverter +import fr.smarquis.fcm.data.model.Message +import fr.smarquis.fcm.data.model.Payload +import org.koin.core.KoinComponent +import org.koin.core.inject + +@Database(entities = [Message::class], version = 1) +@TypeConverters(value = [MapConverter::class, PayloadConverter::class]) +abstract class AppDatabase : RoomDatabase() { + + abstract fun dao(): MessageDao + + object PayloadConverter : KoinComponent { + + private val moshi by inject() + private val adapter = moshi.adapter(Payload::class.java) + + @TypeConverter + @JvmStatic + fun fromJson(data: String): Payload? = adapter.fromJson(data) + + @TypeConverter + @JvmStatic + fun toJson(payload: Payload?): String = adapter.toJson(payload) + + } + + object MapConverter : KoinComponent { + + private val moshi by inject() + private val adapter = moshi.adapter>(Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)) + + @TypeConverter + @JvmStatic + fun stringToMap(data: String): Map = adapter.fromJson(data).orEmpty() + + @TypeConverter + @JvmStatic + fun mapToString(map: Map?): String = adapter.toJson(map) + + } + +} + + + diff --git a/app/src/main/java/fr/smarquis/fcm/data/db/MessageDao.kt b/app/src/main/java/fr/smarquis/fcm/data/db/MessageDao.kt new file mode 100644 index 0000000..411aaca --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/data/db/MessageDao.kt @@ -0,0 +1,19 @@ +package fr.smarquis.fcm.data.db + +import androidx.lifecycle.LiveData +import androidx.room.* +import fr.smarquis.fcm.data.model.Message + +@Dao +interface MessageDao { + + @Query("SELECT * FROM message ORDER BY sentTime DESC") + fun get(): LiveData> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(vararg messages: Message) + + @Delete + suspend fun delete(vararg messages: Message) + +} diff --git a/app/src/main/java/fr/smarquis/fcm/data/db/MigrateFromSharedPreferences.kt b/app/src/main/java/fr/smarquis/fcm/data/db/MigrateFromSharedPreferences.kt new file mode 100644 index 0000000..598374e --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/data/db/MigrateFromSharedPreferences.kt @@ -0,0 +1,41 @@ +package fr.smarquis.fcm.data.db + +import android.app.Application +import android.content.Context +import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import fr.smarquis.fcm.data.model.Message +import fr.smarquis.fcm.data.repository.MessageRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.koin.core.KoinComponent +import org.koin.core.get + + +class MigrateFromSharedPreferences(private val application: Application, private val moshi: Moshi) : RoomDatabase.Callback(), KoinComponent { + + override fun onCreate(db: SupportSQLiteDatabase) { + GlobalScope.launch(Dispatchers.IO) { + val sharedPreferences = application.getSharedPreferences(NAME, Context.MODE_PRIVATE) + val json = sharedPreferences.getString(KEY, null) ?: return@launch + val type = Types.newParameterizedType(MutableList::class.java, Message::class.java) + val adapter = moshi.adapter>(type) + try { + adapter.fromJson(json)?.let { + get().insert(*it.toTypedArray()) + } + } catch (e: Exception) { + e.printStackTrace() + } + sharedPreferences.edit().remove(KEY).apply() + } + } + + companion object { + private const val NAME = "messages" + private const val KEY = "data" + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/data/model/Message.kt b/app/src/main/java/fr/smarquis/fcm/data/model/Message.kt new file mode 100644 index 0000000..f74227a --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/data/model/Message.kt @@ -0,0 +1,20 @@ +package fr.smarquis.fcm.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class Message( + @PrimaryKey + @ColumnInfo(name = "messageId") val messageId: String, + @ColumnInfo(name = "from") val from: String?, + @ColumnInfo(name = "to") val to: String?, + @ColumnInfo(name = "data") val data: Map, + @ColumnInfo(name = "collapseKey") val collapseKey: String?, + @ColumnInfo(name = "messageType") val messageType: String?, + @ColumnInfo(name = "sentTime") val sentTime: Long, + @ColumnInfo(name = "ttl") val ttl: Int, + @ColumnInfo(name = "priority") val priority: Int, + @ColumnInfo(name = "originalPriority") val originalPriority: Int, + @ColumnInfo(name = "payload") val payload: Payload? = null) diff --git a/app/src/main/java/fr/smarquis/fcm/data/model/Payload.kt b/app/src/main/java/fr/smarquis/fcm/data/model/Payload.kt new file mode 100644 index 0000000..7465922 --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/data/model/Payload.kt @@ -0,0 +1,225 @@ +package fr.smarquis.fcm.data.model + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.Intent.* +import android.content.pm.PackageManager +import android.net.Uri +import android.text.TextUtils +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.Builder +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import com.google.firebase.messaging.RemoteMessage +import com.squareup.moshi.Json +import com.squareup.moshi.Moshi +import fr.smarquis.fcm.R +import fr.smarquis.fcm.view.ui.CopyToClipboardActivity +import org.koin.core.KoinComponent +import org.koin.core.inject +import java.io.IOException + +sealed class Payload { + + @IdRes + abstract fun notificationId(): Int + + @DrawableRes + abstract fun icon(): Int + + abstract fun display(): CharSequence? + + abstract fun configure(builder: Builder): Builder + + data class App( + @Json(name = "title") + private val title: String? = null, + @Json(name = "package") + private val packageName: String? = null + ) : Payload() { + + override fun notificationId(): Int = R.id.notification_id_app + + override fun icon(): Int = R.drawable.ic_shop_24dp + + private val display: CharSequence by lazy { + buildSpannedString { + bold { append("title: ") } + append("$title\n") + bold { append("package: ") } + append(packageName) + } + } + + override fun display(): CharSequence? = display + + @SuppressLint("RestrictedApi") + override fun configure(builder: Builder): Builder = builder.apply { + builder.setContentTitle(if (TextUtils.isEmpty(title)) mContext.getString(R.string.payload_app) else title) + .setContentText(packageName) + .addAction(0, mContext.getString(R.string.payload_app_store), PendingIntent.getActivity(mContext, 0, playStore(), 0)) + if (isInstalled(mContext)) { + builder.addAction(0, mContext.getString(R.string.payload_app_uninstall), PendingIntent.getActivity(mContext, 0, uninstall(), 0)) + } + } + + fun playStore(): Intent = Intent(ACTION_VIEW, Uri.parse("market://details?id=$packageName")).apply { + addFlags(FLAG_ACTIVITY_NEW_TASK) + } + + fun uninstall(): Intent = Intent(ACTION_DELETE, Uri.parse("package:$packageName")).apply { + addFlags(FLAG_ACTIVITY_NEW_TASK) + } + + fun isInstalled(context: Context): Boolean = try { + context.packageManager.getPackageInfo(packageName.orEmpty(), 0) != null + } catch (e: PackageManager.NameNotFoundException) { + false + } + + } + + data class Link( + @Json(name = "title") + private val title: String? = null, + @Json(name = "url") + private val url: String? = null, + @Json(name = "open") + val open: Boolean = false + ) : Payload() { + + override fun notificationId(): Int = R.id.notification_id_link + + override fun icon(): Int = R.drawable.ic_link_24dp + + private val display: CharSequence by lazy { + buildSpannedString { + bold { append("title: ") } + append("$title\n") + bold { append("url: ") } + append("$url\n") + bold { append("open: ") } + append(open.toString()) + } + } + + override fun display(): CharSequence? = display + + @SuppressLint("RestrictedApi") + override fun configure(builder: Builder): Builder = builder.apply { + setContentTitle(if (TextUtils.isEmpty(title)) mContext.getString(R.string.payload_link) else title).setContentText(url) + if (!TextUtils.isEmpty(url)) { + addAction(0, mContext.getString(R.string.payload_link_open), PendingIntent.getActivity(mContext, 0, intent(), 0)) + } + } + + fun intent(): Intent = Intent(ACTION_VIEW, Uri.parse(url)).apply { + addFlags(FLAG_ACTIVITY_NEW_TASK) + } + + } + + @Suppress("CanSealedSubClassBeObject") + class Ping : Payload() { + + override fun notificationId(): Int = R.id.notification_id_ping + + override fun icon(): Int = R.drawable.ic_notifications_none_24dp + + override fun display(): CharSequence? = null + + @SuppressLint("RestrictedApi") + override fun configure(builder: Builder): Builder = builder.apply { setContentTitle(mContext.getString(R.string.payload_ping)) } + } + + data class Text( + @Json(name = "title") + private val title: String? = null, + @Json(name = "message") + val text: String? = null, + @Json(name = "clipboard") + val clipboard: Boolean = false + ) : Payload() { + + override fun notificationId(): Int = R.id.notification_id_text + + override fun icon(): Int = R.drawable.ic_chat_24dp + + private val display: CharSequence by lazy { + buildSpannedString { + bold { append("title: ") } + append("$title\n") + bold { append("text: ") } + append("$text\n") + bold { append("clipboard: ") } + append(clipboard.toString()) + } + } + + override fun display(): CharSequence? = display + + @SuppressLint("RestrictedApi") + override fun configure(builder: Builder): Builder = builder.apply { + val intent = Intent(mContext, CopyToClipboardActivity::class.java) + intent.putExtra(EXTRA_TEXT, text) + setContentTitle(if (TextUtils.isEmpty(title)) mContext.getString(R.string.payload_text) else title) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .addAction(0, mContext.getString(R.string.payload_text_copy), PendingIntent.getActivity(mContext, 0, intent, 0)) + } + + } + + data class Raw( + @Json(name = "data") + private val data: Map? = null + ) : Payload(), KoinComponent { + + override fun notificationId(): Int = R.id.notification_id_raw + + override fun icon(): Int = R.drawable.ic_code_24dp + + private val display: CharSequence by lazy { + moshi.adapter>(MutableMap::class.java).indent(" ").toJson(data) + } + + override fun display(): CharSequence? = display + + @SuppressLint("RestrictedApi") + override fun configure(builder: Builder): Builder = builder.apply { + setContentTitle(mContext.getString(R.string.payload_raw)) + .setContentText(display()) + .setStyle(NotificationCompat.BigTextStyle().bigText(display())) + } + } + + companion object : KoinComponent { + + private val moshi by inject() + + private val lut by inject>>() + + fun extract(message: RemoteMessage): Payload { + val data = message.data + val entries: Set> = data.entries + for ((key, value) in entries) { + val clazz = lut[key] ?: continue + try { + val adapter = moshi.adapter(clazz) + val payload = adapter.fromJson(value) + if (payload != null) { + return payload + } + } catch (e: IOException) { + e.printStackTrace() + } + } + return Raw(data) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/data/model/Presence.kt b/app/src/main/java/fr/smarquis/fcm/data/model/Presence.kt new file mode 100644 index 0000000..4abcce1 --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/data/model/Presence.kt @@ -0,0 +1,3 @@ +package fr.smarquis.fcm.data.model + +data class Presence(val token: String? = null, val connected: Boolean = false) diff --git a/app/src/main/java/fr/smarquis/fcm/data/repository/MessageRepository.kt b/app/src/main/java/fr/smarquis/fcm/data/repository/MessageRepository.kt new file mode 100644 index 0000000..05c84ce --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/data/repository/MessageRepository.kt @@ -0,0 +1,14 @@ +package fr.smarquis.fcm.data.repository + +import fr.smarquis.fcm.data.db.MessageDao +import fr.smarquis.fcm.data.model.Message + +class MessageRepository(private val dao: MessageDao) { + + fun get() = dao.get() + + suspend fun insert(vararg messages: Message) = dao.insert(*messages) + + suspend fun delete(vararg messages: Message) = dao.delete(*messages) + +} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/data/services/FcmService.kt b/app/src/main/java/fr/smarquis/fcm/data/services/FcmService.kt new file mode 100644 index 0000000..4b3450c --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/data/services/FcmService.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2017 Simon Marquis + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package fr.smarquis.fcm.data.services + +import androidx.annotation.UiThread +import androidx.annotation.WorkerThread +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import fr.smarquis.fcm.data.model.Message +import fr.smarquis.fcm.data.model.Payload +import fr.smarquis.fcm.data.repository.MessageRepository +import fr.smarquis.fcm.utils.* +import fr.smarquis.fcm.viewmodel.PresenceLiveData +import kotlinx.coroutines.runBlocking +import org.koin.android.ext.android.inject +import java.lang.Boolean.parseBoolean + +class FcmService : FirebaseMessagingService() { + + private val repository: MessageRepository by inject() + + override fun onNewToken(token: String) { + PresenceLiveData.instance(application).fetchToken() + } + + @WorkerThread + override fun onMessageReceived(remoteMessage: RemoteMessage) { + super.onMessageReceived(remoteMessage) + val message = remoteMessage.asMessage() + runBlocking { repository.insert(message) } + uiHandler.post { notifyAndExecute(message) } + } + + @UiThread + private fun notifyAndExecute(message: Message) { + if (!parseBoolean(message.data["hide"])) { + Notifications.show(this, message) + } + val payload = message.payload + when { + payload is Payload.Link && payload.open -> safeStartActivity(payload.intent()) + payload is Payload.Text && payload.clipboard -> applicationContext.copyToClipboard(payload.text) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/di/Modules.kt b/app/src/main/java/fr/smarquis/fcm/di/Modules.kt new file mode 100644 index 0000000..2a258b4 --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/di/Modules.kt @@ -0,0 +1,45 @@ +package fr.smarquis.fcm.di + +import androidx.room.Room +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import fr.smarquis.fcm.data.db.AppDatabase +import fr.smarquis.fcm.data.db.MigrateFromSharedPreferences +import fr.smarquis.fcm.data.model.Payload +import fr.smarquis.fcm.data.repository.MessageRepository +import fr.smarquis.fcm.viewmodel.MessagesViewModel +import org.koin.android.ext.koin.androidContext +import org.koin.android.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val main = module { + viewModel { MessagesViewModel(get(), get()) } + single { MessageRepository(get()) } +} + +val database = module { + single { Room.databaseBuilder(androidContext(), AppDatabase::class.java, "database").addCallback(MigrateFromSharedPreferences(get(), get())).build() } + single { get().dao() } +} + +val json = module { + single { + Moshi.Builder() + .add(PolymorphicJsonAdapterFactory.of(Payload::class.java, "type") + .withSubtype(Payload.App::class.java, "app") + .withSubtype(Payload.Link::class.java, "link") + .withSubtype(Payload.Ping::class.java, "ping") + .withSubtype(Payload.Raw::class.java, "raw") + .withSubtype(Payload.Text::class.java, "text")) + .add(KotlinJsonAdapterFactory()).build() + } + single { + mapOf( + "app" to Payload.App::class.java, + "link" to Payload.Link::class.java, + "ping" to Payload.Ping::class.java, + "text" to Payload.Text::class.java + ) + } +} diff --git a/app/src/main/java/fr/smarquis/fcm/payloads/App.java b/app/src/main/java/fr/smarquis/fcm/payloads/App.java deleted file mode 100644 index f9ae152..0000000 --- a/app/src/main/java/fr/smarquis/fcm/payloads/App.java +++ /dev/null @@ -1,95 +0,0 @@ -package fr.smarquis.fcm.payloads; - -import android.annotation.SuppressLint; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.text.TextUtils; -import android.text.style.StyleSpan; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; - -import com.squareup.moshi.Json; - -import fr.smarquis.fcm.R; -import fr.smarquis.fcm.Truss; - -public class App implements Payload { - - static final String KEY = "app"; - - @Json(name = "title") - private final String title; - - @Json(name = "package") - private final String packageName; - - @Nullable - private transient CharSequence display; - - public App(String packageName, String title) { - this.packageName = packageName; - this.title = title; - } - - @Override - public int icon() { - return R.drawable.ic_shop_24dp; - } - - @Override - public int notificationId() { - return R.id.notification_id_app; - } - - @Override - @NonNull - public NotificationCompat.Builder configure(@NonNull NotificationCompat.Builder builder) { - @SuppressLint("RestrictedApi") Context context = builder.mContext; - builder.setContentTitle(TextUtils.isEmpty(title) ? context.getString(R.string.payload_app) : title) - .setContentText(packageName) - .addAction(0, context.getString(R.string.payload_app_store), PendingIntent.getActivity(context, 0, playStore(), 0)); - if (isInstalled(context)) { - builder.addAction(0, context.getString(R.string.payload_app_uninstall), PendingIntent.getActivity(context, 0, uninstall(), 0)); - } - return builder; - } - - @Override - public synchronized CharSequence display() { - if (display == null) { - display = new Truss() - .pushSpan(new StyleSpan(android.graphics.Typeface.BOLD)).append("title: ").popSpan().append(title).append('\n') - .pushSpan(new StyleSpan(android.graphics.Typeface.BOLD)).append("package: ").popSpan().append(packageName) - .build(); - } - return display; - } - - - Intent playStore() { - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=" + packageName)); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - return intent; - } - - Intent uninstall() { - Intent intent = new Intent(Intent.ACTION_DELETE, Uri.parse("package:" + packageName)); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - return intent; - } - - boolean isInstalled(@NonNull Context context) { - try { - context.getPackageManager().getPackageInfo(packageName, 0); - return true; - } catch (PackageManager.NameNotFoundException e) { - return false; - } - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/payloads/Link.java b/app/src/main/java/fr/smarquis/fcm/payloads/Link.java deleted file mode 100644 index 15ea291..0000000 --- a/app/src/main/java/fr/smarquis/fcm/payloads/Link.java +++ /dev/null @@ -1,84 +0,0 @@ -package fr.smarquis.fcm.payloads; - -import android.annotation.SuppressLint; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.text.TextUtils; -import android.text.style.StyleSpan; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; - -import com.squareup.moshi.Json; - -import fr.smarquis.fcm.R; -import fr.smarquis.fcm.Truss; - -public class Link implements Payload { - - static final String KEY = "link"; - - @Json(name = "title") - private final String title; - @Json(name = "url") - private final String url; - @Json(name = "open") - private final boolean open; - - @Nullable - private transient CharSequence display; - - public Link(String title, String url, boolean open) { - this.title = title; - this.url = url; - this.open = open; - } - - @Override - public int icon() { - return R.drawable.ic_link_24dp; - } - - @Override - public int notificationId() { - return R.id.notification_id_link; - } - - @Override - @NonNull - public NotificationCompat.Builder configure(@NonNull NotificationCompat.Builder builder) { - @SuppressLint("RestrictedApi") Context context = builder.mContext; - builder.setContentTitle(TextUtils.isEmpty(title) ? context.getString(R.string.payload_link) : title) - .setContentText(url); - if (!TextUtils.isEmpty(url)) { - builder.addAction(0, context.getString(R.string.payload_link_open), PendingIntent.getActivity(context, 0, intent(), 0)); - } - return builder; - } - - @Override - public synchronized CharSequence display() { - if (display == null) { - display = new Truss() - .pushSpan(new StyleSpan(android.graphics.Typeface.BOLD)).append("title: ").popSpan().append(title).append('\n') - .pushSpan(new StyleSpan(android.graphics.Typeface.BOLD)).append("url: ").popSpan().append(url).append('\n') - .pushSpan(new StyleSpan(android.graphics.Typeface.BOLD)).append("open: ").popSpan().append(String.valueOf(open)) - .build(); - } - return display; - } - - public boolean open() { - return open; - } - - public Intent intent() { - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - return intent; - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/payloads/Payload.java b/app/src/main/java/fr/smarquis/fcm/payloads/Payload.java deleted file mode 100644 index 742a694..0000000 --- a/app/src/main/java/fr/smarquis/fcm/payloads/Payload.java +++ /dev/null @@ -1,23 +0,0 @@ -package fr.smarquis.fcm.payloads; - -import androidx.annotation.DrawableRes; -import androidx.annotation.IdRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; - -public interface Payload { - - @DrawableRes - int icon(); - - @Nullable - CharSequence display(); - - @IdRes - int notificationId(); - - @NonNull - NotificationCompat.Builder configure(@NonNull NotificationCompat.Builder builder); - -} diff --git a/app/src/main/java/fr/smarquis/fcm/payloads/Payloads.java b/app/src/main/java/fr/smarquis/fcm/payloads/Payloads.java deleted file mode 100644 index 0946fc6..0000000 --- a/app/src/main/java/fr/smarquis/fcm/payloads/Payloads.java +++ /dev/null @@ -1,58 +0,0 @@ -package fr.smarquis.fcm.payloads; - -import androidx.annotation.NonNull; - -import com.google.firebase.messaging.RemoteMessage; -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - -import fr.smarquis.fcm.Messages; - -public final class Payloads { - - public static final PolymorphicJsonAdapterFactory JSON_ADAPTER = PolymorphicJsonAdapterFactory.of(Payload.class, "type") - .withSubtype(App.class, App.KEY) - .withSubtype(Link.class, Link.KEY) - .withSubtype(Ping.class, Ping.KEY) - .withSubtype(Raw.class, Raw.KEY) - .withSubtype(Text.class, Text.KEY); - - private static final Map> CLASSES = new HashMap>() {{ - put(App.KEY, App.class); - put(Link.KEY, Link.class); - put(Ping.KEY, Ping.class); - put(Text.KEY, Text.class); - }}; - - private Payloads() { - } - - @NonNull - public static Payload extract(@NonNull RemoteMessage message) { - Map data = message.getData(); - Set> entries = data.entrySet(); - for (Map.Entry entry : entries) { - String key = entry.getKey(); - String value = entry.getValue(); - Class clazz = CLASSES.get(key); - if (clazz != null) { - try { - JsonAdapter adapter = Messages.moshi().adapter(clazz); - Payload payload = adapter.fromJson(value); - if (payload != null) { - return payload; - } - } catch (IOException e) { - e.printStackTrace(); - } - } - } - return new Raw(data); - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/payloads/Ping.java b/app/src/main/java/fr/smarquis/fcm/payloads/Ping.java deleted file mode 100644 index 9cba863..0000000 --- a/app/src/main/java/fr/smarquis/fcm/payloads/Ping.java +++ /dev/null @@ -1,39 +0,0 @@ -package fr.smarquis.fcm.payloads; - -import android.annotation.SuppressLint; -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; - -import fr.smarquis.fcm.R; - -public class Ping implements Payload { - - static final String KEY = "ping"; - - @Override - public int icon() { - return R.drawable.ic_notifications_none_24dp; - } - - @Override - public int notificationId() { - return R.id.notification_id_ping; - } - - @Override - @NonNull - public NotificationCompat.Builder configure(@NonNull NotificationCompat.Builder builder) { - @SuppressLint("RestrictedApi") Context context = builder.mContext; - return builder.setContentTitle(context.getString(R.string.payload_ping)); - } - - @Nullable - @Override - public CharSequence display() { - return null; - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/payloads/Raw.java b/app/src/main/java/fr/smarquis/fcm/payloads/Raw.java deleted file mode 100644 index b681740..0000000 --- a/app/src/main/java/fr/smarquis/fcm/payloads/Raw.java +++ /dev/null @@ -1,58 +0,0 @@ -package fr.smarquis.fcm.payloads; - -import android.annotation.SuppressLint; -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; - -import com.squareup.moshi.Json; - -import java.util.Map; - -import fr.smarquis.fcm.Messages; -import fr.smarquis.fcm.R; - -public class Raw implements Payload { - - static final String KEY = "raw"; - - @Json(name = "data") - private final Map data; - - Raw(Map data) { - this.data = data; - } - - @Nullable - private transient CharSequence display; - - @Override - public int icon() { - return R.drawable.ic_code_24dp; - } - - @Override - public int notificationId() { - return R.id.notification_id_raw; - } - - @Override - @NonNull - public NotificationCompat.Builder configure(@NonNull NotificationCompat.Builder builder) { - @SuppressLint("RestrictedApi") Context context = builder.mContext; - return builder.setContentTitle(context.getString(R.string.payload_raw)) - .setContentText(display()) - .setStyle(new NotificationCompat.BigTextStyle().bigText(display())); - } - - @Override - public synchronized CharSequence display() { - if (display == null) { - display = Messages.moshi().adapter(Map.class).indent(" ").toJson(data); - } - return display; - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/payloads/Text.java b/app/src/main/java/fr/smarquis/fcm/payloads/Text.java deleted file mode 100644 index ccdd61c..0000000 --- a/app/src/main/java/fr/smarquis/fcm/payloads/Text.java +++ /dev/null @@ -1,87 +0,0 @@ -package fr.smarquis.fcm.payloads; - -import android.annotation.SuppressLint; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.os.Handler; -import android.os.Looper; -import android.text.TextUtils; -import android.text.style.StyleSpan; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; - -import com.squareup.moshi.Json; - -import fr.smarquis.fcm.CopyToClipboardActivity; -import fr.smarquis.fcm.R; -import fr.smarquis.fcm.Truss; -import fr.smarquis.fcm.Util; - -public class Text implements Payload { - - static final String KEY = "text"; - - @Json(name = "title") - private final String title; - @Json(name = "message") - public final String text; - @Json(name = "clipboard") - private final boolean clipboard; - - @Nullable - private transient CharSequence display; - - public Text(String title, String text, boolean clipboard) { - this.title = title; - this.text = text; - this.clipboard = clipboard; - } - - @Override - public int icon() { - return R.drawable.ic_chat_24dp; - } - - @Override - public int notificationId() { - return R.id.notification_id_text; - } - - @Override - @NonNull - public NotificationCompat.Builder configure(@NonNull NotificationCompat.Builder builder) { - @SuppressLint("RestrictedApi") Context context = builder.mContext; - final Intent intent = new Intent(context, CopyToClipboardActivity.class); - intent.putExtra(Intent.EXTRA_TEXT, text); - return builder.setContentTitle(TextUtils.isEmpty(title) ? context.getString(R.string.payload_text) : title) - .setContentText(text) - .setStyle(new NotificationCompat.BigTextStyle().bigText(text)) - .addAction(0, context.getString(R.string.payload_text_copy), PendingIntent.getActivity(context, 0, intent, 0)); - } - - @Override - public synchronized CharSequence display() { - if (display == null) { - display = new Truss() - .pushSpan(new StyleSpan(android.graphics.Typeface.BOLD)).append("title: ").popSpan().append(title).append('\n') - .pushSpan(new StyleSpan(android.graphics.Typeface.BOLD)).append("text: ").popSpan().append(text).append('\n') - .pushSpan(new StyleSpan(android.graphics.Typeface.BOLD)).append("clipboard: ").popSpan().append(String.valueOf(clipboard)) - .build(); - } - return display; - } - - public boolean clipboard() { - return clipboard; - } - - public void copyToClipboard(@NonNull Context context) { - if (Looper.getMainLooper() != Looper.myLooper()) { - new Handler(Looper.getMainLooper()).post(() -> copyToClipboard(context)); - } - Util.copyToClipboard(context.getApplicationContext(), text); - } -} diff --git a/app/src/main/java/fr/smarquis/fcm/payloads/ViewHolder.java b/app/src/main/java/fr/smarquis/fcm/payloads/ViewHolder.java deleted file mode 100644 index 834eaee..0000000 --- a/app/src/main/java/fr/smarquis/fcm/payloads/ViewHolder.java +++ /dev/null @@ -1,170 +0,0 @@ -package fr.smarquis.fcm.payloads; - -import android.content.Context; -import android.text.TextUtils; -import android.view.View; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.Map; - -import fr.smarquis.fcm.Message; -import fr.smarquis.fcm.Messages; -import fr.smarquis.fcm.R; -import fr.smarquis.fcm.TimeAgoTextView; -import fr.smarquis.fcm.Util; - -import static android.view.View.GONE; -import static android.view.View.VISIBLE; - -public final class ViewHolder extends RecyclerView.ViewHolder { - - public interface OnClickListener { - void onClick(@Nullable Message message); - } - - enum Action { - PRIMARY, - SECONDARY, - TERTIARY - } - - @Nullable - private Message message; - - private boolean selected = false; - - private final ImageView icon; - - private final TimeAgoTextView timestamp; - - private final TextView raw, text; - - private final Button button1, button2, button3; - - private final View selector; - - public ViewHolder(@NonNull View itemView, @NonNull OnClickListener listener) { - super(itemView); - selector = itemView.findViewById(R.id.item_selector); - icon = itemView.findViewById(R.id.item_icon); - timestamp = itemView.findViewById(R.id.item_timestamp); - raw = itemView.findViewById(R.id.item_raw); - text = itemView.findViewById(R.id.item_text); - button1 = itemView.findViewById(R.id.item_btn_1); - button2 = itemView.findViewById(R.id.item_btn_2); - button3 = itemView.findViewById(R.id.item_btn_3); - button1.setOnClickListener(v -> execute(Action.PRIMARY, payload())); - button2.setOnClickListener(v -> execute(Action.SECONDARY, payload())); - button3.setOnClickListener(v -> execute(Action.TERTIARY, payload())); - itemView.setOnClickListener(v -> listener.onClick(message)); - itemView.setOnLongClickListener(v -> { - listener.onClick(message); - return true; - }); - } - - @Nullable - private Payload payload() { - return message != null ? message.payload() : null; - } - - private void renderContent() { - selector.setActivated(selected); - if (selected) { - text.setText(null); - text.setVisibility(GONE); - Map data = message != null ? message.data() : null; - String display = data != null ? Messages.moshi().adapter(Message.class).indent(" ").toJson(message) : null; - raw.setText(display); - raw.setVisibility(TextUtils.isEmpty(display) ? GONE : VISIBLE); - } else { - Payload payload = payload(); - CharSequence display = payload != null ? payload.display() : null; - text.setText(display); - text.setVisibility(TextUtils.isEmpty(display) ? GONE : VISIBLE); - raw.setText(null); - raw.setVisibility(GONE); - } - } - - private void render(@NonNull Action action, @NonNull Button button, @Nullable Payload payload) { - if (payload instanceof App) { - switch (action) { - case PRIMARY: - button.setVisibility(VISIBLE); - button.setText(R.string.payload_app_store); - return; - case SECONDARY: - button.setText(R.string.payload_app_uninstall); - button.setVisibility(((App) payload).isInstalled(itemView.getContext()) ? VISIBLE : GONE); - return; - } - } else if (payload instanceof Link) { - if (action == Action.PRIMARY) { - button.setVisibility(VISIBLE); - button.setText(R.string.payload_link_open); - return; - } - } else if (payload instanceof Text) { - if (action == Action.PRIMARY) { - button.setVisibility(VISIBLE); - button.setText(R.string.payload_text_copy); - return; - } - } - - button.setVisibility(GONE); - button.setText(null); - } - - private void execute(@NonNull Action action, @Nullable Payload payload) { - Context context = itemView.getContext(); - if (payload instanceof App) { - App app = (App) payload; - switch (action) { - case PRIMARY: - Util.safeStartActivity(context, app.playStore()); - break; - case SECONDARY: - Util.safeStartActivity(context, app.uninstall()); - break; - } - } else if (payload instanceof Link) { - if (action == Action.PRIMARY) { - Util.safeStartActivity(context, ((Link) payload).intent()); - } - } else if (payload instanceof Text) { - if (action == Action.PRIMARY) { - Util.copyToClipboard(context, ((Text) payload).text); - } - } - } - - public void onBind(@NonNull Message message, boolean selected) { - this.message = message; - this.selected = selected; - icon.setImageResource(message.payload().icon()); - timestamp.setTimestamp(Math.min(message.sentTime(), System.currentTimeMillis())); - renderContent(); - renderButtons(); - } - - private void renderButtons() { - Payload payload = message != null ? message.payload() : null; - render(Action.PRIMARY, button1, payload); - render(Action.SECONDARY, button2, payload); - render(Action.TERTIARY, button3, payload); - } - - public void onUnbind() { - this.message = null; - timestamp.setTimestamp(TimeAgoTextView.NO_TIMESTAMP); - } - -} diff --git a/app/src/main/java/fr/smarquis/fcm/utils/Extensions.kt b/app/src/main/java/fr/smarquis/fcm/utils/Extensions.kt new file mode 100644 index 0000000..d993cd6 --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/utils/Extensions.kt @@ -0,0 +1,65 @@ +package fr.smarquis.fcm.utils + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.core.content.getSystemService +import androidx.preference.PreferenceManager +import com.google.firebase.messaging.RemoteMessage +import fr.smarquis.fcm.R +import fr.smarquis.fcm.data.model.Message +import fr.smarquis.fcm.data.model.Payload +import java.io.PrintWriter +import java.io.StringWriter +import java.util.* + +val uiHandler = Handler(Looper.getMainLooper()) + +fun Throwable.asString(): String = StringWriter().apply { printStackTrace(PrintWriter(this)) }.toString() + +fun Context.safeStartActivity(intent: Intent?) { + try { + startActivity(intent) + } catch (e: Exception) { + Toast.makeText(this, e.asString(), Toast.LENGTH_LONG).show() + } +} + +fun Context.copyToClipboard(text: CharSequence?) { + val clipboard = getSystemService() ?: return + clipboard.setPrimaryClip(ClipData.newPlainText(null, text)) + uiHandler.post { + Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + } +} + +/** + * @return UUID.randomUUID() or a previously generated UUID, stored in SharedPreferences + */ +fun uuid(context: Context): String { + val prefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) + val value = prefs.getString("uuid", null) + if (!value.isNullOrBlank()) { + return value + } + return UUID.randomUUID().toString().apply { + prefs.edit().putString("uuid", this).apply() + } +} + +fun RemoteMessage.asMessage() = Message( + from = from, + to = to, + data = data, + collapseKey = collapseKey, + messageId = messageId.toString(), + messageType = messageType, + sentTime = sentTime, + ttl = ttl, + priority = originalPriority, + originalPriority = priority, + payload = Payload.extract(this)) \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/utils/Notifications.kt b/app/src/main/java/fr/smarquis/fcm/utils/Notifications.kt new file mode 100644 index 0000000..10954d0 --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/utils/Notifications.kt @@ -0,0 +1,55 @@ +package fr.smarquis.fcm.utils + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.O +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat.* +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import fr.smarquis.fcm.R +import fr.smarquis.fcm.data.model.Message +import fr.smarquis.fcm.view.ui.MainActivity + +object Notifications { + + @RequiresApi(api = O) + private fun createNotificationChannel(context: Context) { + val id = context.getString(R.string.notification_channel_id) + val name: CharSequence = context.getString(R.string.notification_channel_name) + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(id, name, importance) + channel.enableLights(true) + channel.enableVibration(true) + channel.setShowBadge(true) + context.getSystemService()?.createNotificationChannel(channel) + } + + private fun getNotificationBuilder(context: Context): Builder { + if (SDK_INT >= O) { + createNotificationChannel(context) + } + return Builder(context, context.getString(R.string.notification_channel_id)) + .setColor(ContextCompat.getColor(context, R.color.colorPrimary)) + .setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), 0)) + .setLocalOnly(true) + .setAutoCancel(true) + .setDefaults(DEFAULT_ALL) + .setPriority(PRIORITY_MAX) + .setCategory(CATEGORY_MESSAGE) + } + + fun show(context: Context, message: Message) { + val payload = message.payload ?: return + val notification = payload.configure(getNotificationBuilder(context).setSmallIcon(payload.icon())).build() + context.getSystemService()?.notify(message.messageId, payload.notificationId(), notification) + } + + fun removeAll(context: Context) { + context.getSystemService()?.cancelAll() + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/utils/Singleton.kt b/app/src/main/java/fr/smarquis/fcm/utils/Singleton.kt new file mode 100644 index 0000000..e3aa2e7 --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/utils/Singleton.kt @@ -0,0 +1,28 @@ +package fr.smarquis.fcm.utils + +open class Singleton(creator: (A) -> T) { + + private var creator: ((A) -> T)? = creator + @Volatile + private var instance: T? = null + + fun instance(arg: A): T { + val i = instance + if (i != null) { + return i + } + + return synchronized(this) { + val i2 = instance + if (i2 != null) { + i2 + } else { + val created = creator!!(arg) + instance = created + creator = null + created + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/view/adapter/MessagesAdapter.kt b/app/src/main/java/fr/smarquis/fcm/view/adapter/MessagesAdapter.kt new file mode 100644 index 0000000..6c937c7 --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/view/adapter/MessagesAdapter.kt @@ -0,0 +1,198 @@ +/* + * Copyright 2017 Simon Marquis + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package fr.smarquis.fcm.view.adapter + +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.squareup.moshi.Moshi +import fr.smarquis.fcm.R +import fr.smarquis.fcm.data.model.Message +import fr.smarquis.fcm.data.model.Payload +import fr.smarquis.fcm.utils.copyToClipboard +import fr.smarquis.fcm.utils.safeStartActivity +import fr.smarquis.fcm.view.adapter.MessagesAdapter.Action.PRIMARY +import fr.smarquis.fcm.view.adapter.MessagesAdapter.Action.SECONDARY +import fr.smarquis.fcm.view.ui.TimeAgoTextView +import kotlin.math.min + +class MessagesAdapter(private val moshi: Moshi) : ListAdapter(DIFF) { + + companion object { + private val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Message, newItem: Message): Boolean = oldItem.messageId == newItem.messageId + override fun areContentsTheSame(oldItem: Message, newItem: Message): Boolean = oldItem == newItem + } + private val selection: androidx.collection.ArrayMap = androidx.collection.ArrayMap() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val view = inflater.inflate(R.layout.item_payload, parent, false) + return ViewHolder(view, ::toggle) + } + + private fun toggle(message: Message, viewHolder: ViewHolder) { + selection[message.messageId] = !(selection[message.messageId] ?: false) + notifyItemChanged(viewHolder.adapterPosition) + } + + public override fun getItem(position: Int): Message = super.getItem(position) + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) = viewHolder.onBind(getItem(position), selection[getItem(position).messageId] ?: false) + + override fun onViewRecycled(holder: ViewHolder) = holder.onUnbind() + + enum class Action { + PRIMARY, SECONDARY + } + + inner class ViewHolder(itemView: View, private val listener: (Message, ViewHolder) -> Unit) : RecyclerView.ViewHolder(itemView) { + + private var message: Message? = null + private var selected = false + + private val icon: ImageView = itemView.findViewById(R.id.item_icon) + private val timestamp: TimeAgoTextView = itemView.findViewById(R.id.item_timestamp) + private val raw: TextView = itemView.findViewById(R.id.item_raw) + private val text: TextView = itemView.findViewById(R.id.item_text) + private val button1: Button = itemView.findViewById(R.id.item_btn_1) + private val button2: Button = itemView.findViewById(R.id.item_btn_2) + private val selector: View = itemView.findViewById(R.id.item_selector) + + init { + button1.setOnClickListener { execute(PRIMARY, payload()) } + button2.setOnClickListener { execute(SECONDARY, payload()) } + itemView.setOnClickListener { message?.let { listener(it, this) } } + itemView.setOnLongClickListener { message?.let { listener(it, this) }.let { true } } + } + + private fun payload(): Payload? = message?.payload + + private fun execute(action: Action, payload: Payload?) { + val context = itemView.context + when (payload) { + is Payload.App -> { + when (action) { + PRIMARY -> context.safeStartActivity(payload.playStore()) + SECONDARY -> context.safeStartActivity(payload.uninstall()) + } + } + is Payload.Link -> { + if (action == PRIMARY) { + context.safeStartActivity(payload.intent()) + } + } + is Payload.Text -> { + if (action == PRIMARY) { + context.copyToClipboard(payload.text) + } + } + } + } + + fun onBind(message: Message, selected: Boolean) { + this.message = message + this.selected = selected + icon.setImageResource(message.payload?.icon() ?: 0) + timestamp.timestamp = min(message.sentTime, System.currentTimeMillis()) + renderContent() + renderButtons() + } + + private fun renderContent() { + selector.isActivated = selected + if (selected) { + text.text = null + text.visibility = GONE + val data: Map<*, *>? = message?.data + val display = if (data != null) moshi.adapter(Message::class.java).indent(" ").toJson(message) else null + raw.text = display + raw.visibility = if (TextUtils.isEmpty(display)) GONE else VISIBLE + } else { + val payload = payload() + val display = payload?.display() + text.text = display + text.visibility = if (TextUtils.isEmpty(display)) GONE else VISIBLE + raw.text = null + raw.visibility = GONE + } + } + + private fun renderButtons() { + mapOf( + PRIMARY to button1, + SECONDARY to button2 + ).forEach { (action, button) -> + render(action, button, message?.payload) + } + } + + private fun render(action: Action, button: Button, payload: Payload?) { + if (selected) { + button.visibility = GONE + return + } + when (payload) { + is Payload.App -> { + when (action) { + PRIMARY -> { + button.visibility = VISIBLE + button.setText(R.string.payload_app_store) + return + } + SECONDARY -> { + button.setText(R.string.payload_app_uninstall) + button.visibility = if (payload.isInstalled(itemView.context)) VISIBLE else GONE + return + } + } + } + is Payload.Link -> { + if (action == PRIMARY) { + button.visibility = VISIBLE + button.setText(R.string.payload_link_open) + return + } + } + is Payload.Text -> { + if (action == PRIMARY) { + button.visibility = VISIBLE + button.setText(R.string.payload_text_copy) + return + } + } + } + button.visibility = GONE + button.text = null + } + + fun onUnbind() { + message = null + timestamp.timestamp = TimeAgoTextView.NO_TIMESTAMP + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/CopyToClipboardActivity.java b/app/src/main/java/fr/smarquis/fcm/view/ui/CopyToClipboardActivity.kt similarity index 57% rename from app/src/main/java/fr/smarquis/fcm/CopyToClipboardActivity.java rename to app/src/main/java/fr/smarquis/fcm/view/ui/CopyToClipboardActivity.kt index 4924088..d5971e9 100644 --- a/app/src/main/java/fr/smarquis/fcm/CopyToClipboardActivity.java +++ b/app/src/main/java/fr/smarquis/fcm/view/ui/CopyToClipboardActivity.kt @@ -13,21 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package fr.smarquis.fcm.view.ui -package fr.smarquis.fcm; +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import fr.smarquis.fcm.utils.copyToClipboard -import android.app.Activity; -import android.content.Intent; -import android.os.Bundle; +class CopyToClipboardActivity : Activity() { -import static fr.smarquis.fcm.Util.copyToClipboard; - -public class CopyToClipboardActivity extends Activity { - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - copyToClipboard(this, getIntent().getCharSequenceExtra(Intent.EXTRA_TEXT)); - finish(); + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + copyToClipboard(intent.getCharSequenceExtra(Intent.EXTRA_TEXT)) + finish() } -} + +} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/view/ui/MainActivity.kt b/app/src/main/java/fr/smarquis/fcm/view/ui/MainActivity.kt new file mode 100644 index 0000000..a2ec174 --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/view/ui/MainActivity.kt @@ -0,0 +1,196 @@ +/* + * Copyright 2017 Simon Marquis + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package fr.smarquis.fcm.view.ui + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.content.DialogInterface.BUTTON_NEGATIVE +import android.content.DialogInterface.BUTTON_POSITIVE +import android.content.Intent +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View.INVISIBLE +import android.view.View.VISIBLE +import android.widget.EditText +import android.widget.Toast +import android.widget.Toast.LENGTH_LONG +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import com.google.firebase.messaging.FirebaseMessaging +import fr.smarquis.fcm.R +import fr.smarquis.fcm.data.model.Message +import fr.smarquis.fcm.data.model.Presence +import fr.smarquis.fcm.utils.Notifications.removeAll +import fr.smarquis.fcm.utils.asString +import fr.smarquis.fcm.utils.safeStartActivity +import fr.smarquis.fcm.view.adapter.MessagesAdapter +import fr.smarquis.fcm.viewmodel.MessagesViewModel +import kotlinx.android.synthetic.main.activity_main.* +import org.koin.android.ext.android.get +import org.koin.android.viewmodel.ext.android.viewModel +import java.util.concurrent.TimeUnit +import java.util.regex.Pattern + +class MainActivity : AppCompatActivity() { + + private val viewModel: MessagesViewModel by viewModel() + + private val messagesAdapter: MessagesAdapter = MessagesAdapter(get()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + viewModel.presence.observe(this, Observer { updatePresence(it) }) + viewModel.messages.observe(this, Observer { updateMessages(it) }) + + messagesAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + super.onItemRangeInserted(positionStart, itemCount) + if (positionStart != 0 || itemCount > 1) return + recycler_view.post { recycler_view.smoothScrollToPosition(0) } + } + }) + recycler_view.apply { + setHasFixedSize(true) + val horizontal = resources.getDimensionPixelSize(R.dimen.unit_4) + val vertical = resources.getDimensionPixelSize(R.dimen.unit_1) + addItemDecoration(SpacingItemDecoration(horizontal, vertical)) + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean = false + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) { + val message = messagesAdapter.getItem(viewHolder.adapterPosition) + viewModel.delete(message) + Snackbar.make(recycler_view, getString(R.string.snackbar_item_deleted, 1), Snackbar.LENGTH_LONG).setAction(R.string.snackbar_item_undo) { + viewModel.insert(message) + }.show() + } + }).attachToRecyclerView(this) + adapter = messagesAdapter + } + + } + + private fun updateMessages(messages: List) { + messagesAdapter.submitList(messages) { + empty_view.visibility = if (messagesAdapter.itemCount > 0) INVISIBLE else VISIBLE + invalidateOptionsMenu() + } + } + + private fun updatePresence(presence: Presence) { + supportActionBar?.subtitle = when (presence.token) { + null -> getString(R.string.fetching) + else -> presence.token + } + invalidateOptionsMenu() + } + + override fun onResume() { + super.onResume() + removeAll(this) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_main, menu) + return true + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + menu.findItem(R.id.action_presence).setIcon(if (viewModel.presence.value.connected) android.R.drawable.presence_online else android.R.drawable.presence_invisible) + menu.findItem(R.id.action_share_token).isVisible = !viewModel.presence.value.token.isNullOrEmpty() + menu.findItem(R.id.action_delete_all).isVisible = messagesAdapter.itemCount > 0 + return super.onPrepareOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_share_token -> shareToken() + R.id.action_invalidate_token -> viewModel.presence.resetToken() + R.id.action_topics -> showTopicsDialog() + R.id.action_delete_all -> showDeleteDialog() + } + return super.onOptionsItemSelected(item) + } + + private fun showDeleteDialog() { + val messages = messagesAdapter.currentList.toTypedArray() + AlertDialog.Builder(this) + .setMessage(getString(R.string.dialog_delete_all_title, messages.size)) + .setPositiveButton(R.string.dialog_delete_all_positive) { _: DialogInterface?, _: Int -> + viewModel.delete(*messages) + removeAll(this) + Snackbar.make(recycler_view, getString(R.string.snackbar_item_deleted, messages.size), TimeUnit.SECONDS.toMillis(10).toInt()) + .setAction(R.string.snackbar_item_undo) { viewModel.insert(*messages) } + .show() + } + .setNegativeButton(R.string.dialog_delete_all_negative) { _: DialogInterface?, _: Int -> } + .show() + } + + private fun shareToken() { + val token = viewModel.presence.value.token ?: return + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, token) + } + safeStartActivity(Intent.createChooser(intent, getString(R.string.menu_share_token))) + } + + private fun showTopicsDialog() { + // Extracted from com.google.firebase.messaging.FirebaseMessaging + val pattern = Pattern.compile("[a-zA-Z0-9-_.~%]{1,900}") + @SuppressLint("InflateParams") val view = LayoutInflater.from(this).inflate(R.layout.topics_dialog, null, false) + val input = view.findViewById(R.id.dialog_input_value) + val messaging = FirebaseMessaging.getInstance() + val dialog = AlertDialog.Builder(this) + .setView(view) + .setPositiveButton(R.string.topics_subscribe) { _: DialogInterface?, _: Int -> + val topic = input.text.toString() + messaging.subscribeToTopic(topic) + .addOnSuccessListener(this) { Toast.makeText(this, getString(R.string.topics_subscribed, topic), LENGTH_LONG).show() } + .addOnFailureListener(this) { Toast.makeText(this, it.asString(), LENGTH_LONG).show() } + } + .setNegativeButton(R.string.topics_unsubscribe) { _: DialogInterface?, _: Int -> + val topic = input.text.toString() + messaging.unsubscribeFromTopic(topic) + .addOnSuccessListener(this) { Toast.makeText(this, getString(R.string.topics_unsubscribed, topic), LENGTH_LONG).show() } + .addOnFailureListener(this) { Toast.makeText(this, it.asString(), LENGTH_LONG).show() } + }.show() + input.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable) { + val matches = pattern.matcher(s).matches() + dialog.getButton(BUTTON_POSITIVE).isEnabled = matches + dialog.getButton(BUTTON_NEGATIVE).isEnabled = matches + } + }) + // Trigger afterTextChanged() + input.text = null + input.requestFocus() + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/view/ui/SpacingItemDecoration.kt b/app/src/main/java/fr/smarquis/fcm/view/ui/SpacingItemDecoration.kt new file mode 100644 index 0000000..ede73bf --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/view/ui/SpacingItemDecoration.kt @@ -0,0 +1,23 @@ +package fr.smarquis.fcm.view.ui + +import android.graphics.Rect +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.annotation.Px +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration +import kotlin.math.max + +class SpacingItemDecoration(@param:Px private val horizontal: Int, @param:Px private val vertical: Int) : ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val dy = vertical / 2 + val dx = when (val viewWidth = view.layoutParams.width) { + MATCH_PARENT, WRAP_CONTENT -> horizontal + else -> max(horizontal, (parent.width - viewWidth) / 2) + } + outRect.set(dx, dy, dx, dy) + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/view/ui/TimeAgoTextView.kt b/app/src/main/java/fr/smarquis/fcm/view/ui/TimeAgoTextView.kt new file mode 100644 index 0000000..92b9dac --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/view/ui/TimeAgoTextView.kt @@ -0,0 +1,87 @@ +package fr.smarquis.fcm.view.ui + +import android.content.Context +import android.os.CountDownTimer +import android.text.format.DateUtils +import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE +import android.text.format.DateUtils.SECOND_IN_MILLIS +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import java.util.concurrent.TimeUnit.* +import kotlin.math.abs + +class TimeAgoTextView +@JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatTextView(context, attrs, defStyleAttr) { + + var timestamp: Long = NO_TIMESTAMP + set(value) { + field = value + renderTimestamp() + restartCountDown() + } + private var countDownTimer: CountDownTimer? = null + private var isCounting = false + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + isCounting = true + startCountDown() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + isCounting = false + stopCountDown() + } + + private fun startCountDown() { + if (!isCounting || timestamp <= NO_TIMESTAMP || countDownTimer != null) return + val now = System.currentTimeMillis() + val diff = abs(now - timestamp) + val millisInFuture: Long + val countDownInterval: Long + when { + diff < MINUTE_TO_MILLIS -> { + millisInFuture = MINUTE_TO_MILLIS + countDownInterval = SECOND_TO_MILLIS + } + diff < HOUR_TO_MILLIS -> { + millisInFuture = HOUR_TO_MILLIS + countDownInterval = MINUTE_TO_MILLIS + } + else -> /*ignore*/ return + } + countDownTimer = object : CountDownTimer(millisInFuture, countDownInterval) { + override fun onTick(millisUntilFinished: Long) = renderTimestamp() + override fun onFinish() = restartCountDown() + }.start() + } + + private fun restartCountDown() { + stopCountDown() + startCountDown() + } + + private fun stopCountDown() { + countDownTimer?.cancel() + countDownTimer = null + } + + private fun renderTimestamp() { + text = if (timestamp > NO_TIMESTAMP) + DateUtils.getRelativeTimeSpanString(timestamp, System.currentTimeMillis(), SECOND_IN_MILLIS, FORMAT_ABBREV_RELATIVE) + else + null + } + + companion object { + const val NO_TIMESTAMP: Long = 0 + private val SECOND_TO_MILLIS = SECONDS.toMillis(1) + private val MINUTE_TO_MILLIS = MINUTES.toMillis(1) + private val HOUR_TO_MILLIS = HOURS.toMillis(1) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/viewmodel/MessagesViewModel.kt b/app/src/main/java/fr/smarquis/fcm/viewmodel/MessagesViewModel.kt new file mode 100644 index 0000000..d84e72a --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/viewmodel/MessagesViewModel.kt @@ -0,0 +1,20 @@ +package fr.smarquis.fcm.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import fr.smarquis.fcm.data.model.Message +import fr.smarquis.fcm.data.repository.MessageRepository +import kotlinx.coroutines.launch + +class MessagesViewModel(application: Application, private val repository: MessageRepository) : AndroidViewModel(application) { + + val presence = PresenceLiveData.instance(application) + + val messages = repository.get() + + fun insert(vararg messages: Message) = viewModelScope.launch { repository.insert(*messages) } + + fun delete(vararg messages: Message) = viewModelScope.launch { repository.delete(*messages) } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/smarquis/fcm/viewmodel/PresenceLiveData.kt b/app/src/main/java/fr/smarquis/fcm/viewmodel/PresenceLiveData.kt new file mode 100644 index 0000000..404725b --- /dev/null +++ b/app/src/main/java/fr/smarquis/fcm/viewmodel/PresenceLiveData.kt @@ -0,0 +1,84 @@ +package fr.smarquis.fcm.viewmodel + +import android.app.Application +import android.os.Build.MANUFACTURER +import android.os.Build.MODEL +import androidx.lifecycle.LiveData +import com.google.android.gms.tasks.Tasks +import com.google.firebase.database.* +import com.google.firebase.iid.FirebaseInstanceId +import fr.smarquis.fcm.data.model.Presence +import fr.smarquis.fcm.utils.Singleton +import fr.smarquis.fcm.utils.uuid +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Locale.ROOT + +class PresenceLiveData(application: Application) : LiveData(Presence()), ValueEventListener { + + private val instanceId = FirebaseInstanceId.getInstance() + private val database = FirebaseDatabase.getInstance() + + private val presenceRef: DatabaseReference = database.getReference(".info/connected") + private val connectionRef: DatabaseReference = database.getReference("devices/${uuid(application)}") + + init { + connectionRef.onDisconnect().removeValue() + } + + companion object : Singleton(::PresenceLiveData) + + override fun getValue(): Presence = super.getValue() ?: Presence() + + fun fetchToken() = GlobalScope.launch(Dispatchers.Main) { + val token = withContext(Dispatchers.IO) { + Tasks.await(instanceId.instanceId).token + } + value = value.copy(token = token) + connectionRef.setValue(payload(token)) + } + + fun resetToken() = GlobalScope.launch(Dispatchers.Main) { + value = value.copy(token = null) + connectionRef.removeValue() + withContext(Dispatchers.IO) { + try { + instanceId.deleteInstanceId() + } catch (e: Exception) { + e.printStackTrace() + } + // Wait for FirebaseMessagingService.onNewToken() + } + } + + override fun onActive() { + fetchToken() + presenceRef.addValueEventListener(this) + DatabaseReference.goOnline() + } + + override fun onInactive() { + connectionRef.removeValue() + DatabaseReference.goOffline() + presenceRef.removeEventListener(this) + value = value.copy(connected = false) + } + + override fun onCancelled(error: DatabaseError) { + value = value.copy(connected = false) + } + + override fun onDataChange(snapshot: DataSnapshot) { + value = value.copy(connected = snapshot.getValue(Boolean::class.java) ?: false) + fetchToken() + } + + private fun payload(token: String) = mapOf( + "name" to if (MODEL.toLowerCase(ROOT).startsWith(MANUFACTURER.toLowerCase(ROOT))) MODEL else MANUFACTURER.toUpperCase(ROOT) + " " + MODEL, + "token" to token, + "timestamp" to ServerValue.TIMESTAMP + ) + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/fast_scroll_thumb.xml b/app/src/main/res/drawable/fast_scroll_thumb.xml new file mode 100644 index 0000000..456fa0b --- /dev/null +++ b/app/src/main/res/drawable/fast_scroll_thumb.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fast_scroll_track.xml b/app/src/main/res/drawable/fast_scroll_track.xml new file mode 100644 index 0000000..874a6dc --- /dev/null +++ b/app/src/main/res/drawable/fast_scroll_track.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_background.xml b/app/src/main/res/drawable/selector_background.xml index d39b13a..0a25e9b 100644 --- a/app/src/main/res/drawable/selector_background.xml +++ b/app/src/main/res/drawable/selector_background.xml @@ -9,4 +9,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index e700d19..acab59c 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -16,10 +16,11 @@ ~ limitations under the License. --> + tools:context=".view.ui.MainActivity"> diff --git a/app/src/main/res/layout/item_payload.xml b/app/src/main/res/layout/item_payload.xml index d6053ce..431fc91 100644 --- a/app/src/main/res/layout/item_payload.xml +++ b/app/src/main/res/layout/item_payload.xml @@ -16,20 +16,20 @@ ~ limitations under the License. --> - + android:foreground="?selectableItemBackground"> + android:background="@drawable/selector_background" + android:elevation="2dp" + tools:ignore="UnusedAttribute"> - -