initial commit: mattermost-push-plugin

This commit is contained in:
toki 2026-05-25 13:45:37 +09:00
commit 678302bc3e
156 changed files with 6480 additions and 0 deletions

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/

36
.metadata Normal file
View file

@ -0,0 +1,36 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa"
channel: "stable"
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
- platform: android
create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
- platform: ios
create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
- platform: macos
create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

3
CHANGELOG.md Normal file
View file

@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.

1
LICENSE Normal file
View file

@ -0,0 +1 @@
TODO: Add your license here.

135
README.md Normal file
View file

@ -0,0 +1,135 @@
# mattermost_push_plugin
An Android-first Flutter plugin for receiving Mattermost push notifications, displaying system notifications, handling tap events, processing inline replies, delivering ACKs, and securely storing device tokens, authentication tokens, and signing keys.
---
## Support Matrix
| Platform | Support Status | Native Features Implemented |
|---|---|---|
| **Android** | **Full (Production Ready)** | Kotlin, Native FCM Service, Room DB, JJWT Verification, OkHttp ACK Client, Notification Builder, Inline Reply Receiver, Dismiss Receiver |
| **iOS** | No-op Scaffold (Stub) | Future roadmap milestone |
| **macOS** | No-op Scaffold (Stub) | Future roadmap milestone |
---
## Core Responsibilities
This plugin isolates the Mattermost push notification domain logic from the host application.
```mermaid
graph TD
A[Mattermost Server] -->|Secure JWT Push| B(Plugin Native FCM Service)
B -->|1. Signature Verification JJWT| C{Valid?}
C -->|Yes| D[2. Store & Build Notification]
C -->|No| E[Drop Push]
D -->|3. OkHttp ACK API| A
D -->|4. Display System Notification| F(Android Status Bar)
F -->|Tap Notification| G[5. Method Channel Opened Event]
F -->|Inline Reply| H[6. Broadcast Reply Receiver]
H -->|Deliver via OkHttp| A
G -->|Flutter Stream| I(Host App Navigation)
```
---
## Public Dart API Reference
Get the singleton instance via `MattermostPushPlugin.instance`.
### 1. Initialization
Initialize the plugin channel subscriptions, listen to token refreshes, and request notification permissions:
```dart
import 'package:mattermost_push_plugin/mattermost_push_plugin.dart';
await MattermostPushPlugin.instance.initialize();
```
### 2. Streams & Callbacks
Set up listeners and navigation callbacks inside your app initialization flow:
```dart
// 1. Listen to raw native notification events (Message, Clear, Session, etc.)
MattermostPushPlugin.instance.onNotification.listen((data) {
print("Raw push received: $data");
});
// 2. Listen to user interaction (notification tap) events
MattermostPushPlugin.instance.onNotificationOpened.listen((event) {
print("Notification clicked! Channel: ${event.channelId}, Root: ${event.rootId}");
});
// 3. Register navigation handlers (automatically triggered by onNotificationOpened)
MattermostPushPlugin.instance.onNavigateToChannel = (String serverUrl, String channelId) {
// Navigate the app to the specific channel
};
MattermostPushPlugin.instance.onNavigateToThread = (String serverUrl, String rootId) {
// Navigate the app to the specific CRT thread
};
// 4. Triggered when the device token is registered/refreshed
MattermostPushPlugin.instance.onDeviceTokenReady = (String deviceToken) {
// e.g. "android_rn-v2:YOUR_FCM_TOKEN"
// Send this token to the Mattermost server for token registration
};
```
### 3. Credential & Signing Key Storing
Securely write server-specific secrets to native persistent storage. These are required by the native service to decrypt and verify the authenticity of push messages.
```dart
// Save authenticating token for HTTP ACK requests
await MattermostPushPlugin.instance.setAuthToken(
"https://mattermost.example.com",
"YOUR_USER_AUTH_TOKEN",
identifier: "user_session_id", // optional
);
// Save public signing key for verification
await MattermostPushPlugin.instance.setSigningKey(
"https://mattermost.example.com",
"YOUR_SERVER_PUBLIC_SIGNING_KEY",
);
// Clear token on logout
await MattermostPushPlugin.instance.clearAuthToken("https://mattermost.example.com");
// Fetch the formatted device token
String? token = await MattermostPushPlugin.instance.getDeviceToken();
```
---
## Host App Integration & Responsibilities
The host application (`apps/mobile`) acts as the consumer of the plugin and is responsible for:
1. **Firebase Configuration**: Include the `google-services.json` in `android/app/` and initialize Firebase in the Dart main block.
2. **Authentication Flow**: Call `setAuthToken` and `setSigningKey` on successful Mattermost login, and `clearAuthToken` on logout.
3. **App Navigation**: Implement `onNavigateToChannel` and `onNavigateToThread` callbacks to route users inside the Flutter UI.
4. **FCM Token Registration**: Send the formatted token received in `onDeviceTokenReady` to the Mattermost server.
### Firebase Messaging Manifest Collision Guard
This plugin implements a custom Android FCM service (`MattermostFirebaseMessagingService`) that consumes incoming FCM payloads. To prevent manifest merge collisions with the standard Flutter `firebase_messaging` library, the plugin's `AndroidManifest.xml` disables the generic Flutter Firebase service via `tools:node="remove"`.
Ensure that the host app does not redeclare duplicate FCM services unless explicitly configured to do so.
---
## Verification & Manual Smoke Testing Checklist
Since full end-to-end FCM flows cannot be automated in headless test runners, verify functionality manually using the following checklist:
- [ ] **FCM Payload Receipt**: Verify that sending an FCM notification payload targeting the host application successfully wakes up `MattermostFirebaseMessagingService`.
- [ ] **Signature Verification**: Ensure that a valid push payload signed by the Mattermost server's private key compiles and passes the native JJWT verification using the stored signing key.
- [ ] **Notification Display**: Verify the system notification builds with the correct title, message, avatar (if supplied), and CRT threading hierarchy.
- [ ] **ACK Delivery**: Verify the plugin sends a HTTP `POST` receipt delivery ACK back to the Mattermost server and logs a `200 OK` or `201 Created` response.
- [ ] **Notification Tap & Route**: Tap the system notification and verify the host app launches/resumes, triggers the `onNotificationOpened` stream, and executes the designated navigation callback.
- [ ] **Inline Reply Action**: Click "Reply" directly on the system notification. Type a reply, press send, and verify the native broadcast receiver intercepts the text, writes it to the local Room database, and sends it via HTTP to the Mattermost API.
- [ ] **Notification Dismiss (Clear)**: Swipe away or dismiss a notification and verify the `NotificationDismissService` is triggered, clearing the corresponding notification records natively.
- [ ] **Token Storage & Generation**: Ensure the generated token has the correct `android_rn-v2:` prefix and is saved securely in the device's native database/shared preferences.

4
analysis_options.yaml Normal file
View file

@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

9
android/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx

95
android/build.gradle.kts Normal file
View file

@ -0,0 +1,95 @@
group = "com.tokilabs.mattermost_push_plugin"
version = "1.0-SNAPSHOT"
buildscript {
val kotlinVersion = "2.2.20"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin-api:$kotlinVersion")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
plugins {
id("com.android.library")
id("kotlin-android")
id("kotlin-kapt")
}
android {
namespace = "com.tokilabs.mattermost_push_plugin"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
sourceSets {
getByName("main") {
java.srcDirs("src/main/kotlin", "src/main/java")
}
getByName("test") {
java.srcDirs("src/test/kotlin")
}
}
defaultConfig {
minSdk = 24
}
testOptions {
unitTests {
isIncludeAndroidResources = true
all {
it.useJUnitPlatform()
it.outputs.upToDateWhen { false }
it.testLogging {
events("passed", "skipped", "failed", "standardOut", "standardError")
showStandardStreams = true
}
}
}
}
}
dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
implementation("io.jsonwebtoken:jjwt-api:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5")
runtimeOnly("io.jsonwebtoken:jjwt-orgjson:0.12.5") {
exclude(group = "org.json", module = "json")
}
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.room:room-runtime:2.7.0-rc01")
implementation("androidx.room:room-ktx:2.7.0-rc01")
kapt("androidx.room:room-compiler:2.7.0-rc01")
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0")
}

1
android/settings.gradle Normal file
View file

@ -0,0 +1 @@
rootProject.name = 'mattermost_push_plugin'

View file

@ -0,0 +1 @@
rootProject.name = 'mattermost_push_plugin'

View file

@ -0,0 +1,43 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application>
<!-- Flutter firebase_messaging 플러그인의 기본 컴포넌트는 사용하지 않는다. -->
<receiver
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
tools:node="remove" />
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingBackgroundService"
tools:node="remove" />
<provider
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingInitProvider"
android:authorities="${applicationId}.flutterfirebasemessaginginitprovider"
tools:node="remove" />
<service
android:name="com.tokilabs.mattermost_push_plugin.MattermostFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
<service
android:name="com.tokilabs.mattermost_push_plugin.NotificationDismissService"
android:enabled="true"
android:exported="false" />
<receiver
android:name="com.tokilabs.mattermost_push_plugin.NotificationReplyBroadcastReceiver"
android:enabled="true"
android:exported="false" />
</application>
</manifest>

View file

@ -0,0 +1,33 @@
package com.tokilabs.mattermost_push_plugin;
import android.app.IntentService;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import com.tokilabs.mattermost_push_plugin.helpers.NotificationHelper;
@SuppressWarnings("deprecation")
public class NotificationDismissService extends IntentService {
private static final String TAG = "NotifDismissService";
private static final String PUSH_NOTIFICATION_EXTRA_NAME = "pushNotification";
public NotificationDismissService() {
super("notificationDismissService");
}
@Override
protected void onHandleIntent(Intent intent) {
if (intent == null) return;
final Context context = getApplicationContext();
final Bundle bundle = intent.getBundleExtra(PUSH_NOTIFICATION_EXTRA_NAME);
if (bundle != null) {
NotificationHelper.INSTANCE.dismissNotification(context, bundle);
Log.i(TAG, "Dismiss notification id=" + NotificationHelper.INSTANCE.getNotificationId(bundle));
}
}
}

View file

@ -0,0 +1,140 @@
package com.tokilabs.mattermost_push_plugin;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.RemoteInput;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.core.app.Person;
import com.tokilabs.mattermost_push_plugin.helpers.CustomPushNotificationHelper;
import com.tokilabs.mattermost_push_plugin.helpers.Network;
import org.json.JSONException;
import org.json.JSONObject;
public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
private static final String TAG = "NotifReplyReceiver";
private Context mContext;
private Bundle bundle;
private NotificationManager notificationManager;
@Override
public void onReceive(Context context, Intent intent) {
try {
final CharSequence message = getReplyMessage(intent);
if (message == null) return;
mContext = context;
bundle = intent.getBundleExtra(CustomPushNotificationHelper.NOTIFICATION);
notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
final int notificationId = intent.getIntExtra(CustomPushNotificationHelper.NOTIFICATION_ID, -1);
final String serverUrl = bundle.getString("server_url");
if (serverUrl != null) {
replyToMessage(serverUrl, notificationId, message);
} else {
onReplyFailed(notificationId);
}
} catch (Exception e) {
Log.e(TAG, "onReceive error: " + e.getMessage());
e.printStackTrace();
}
}
protected void replyToMessage(final String serverUrl, final int notificationId,
final CharSequence message) {
final String channelId = bundle.getString("channel_id");
final String postId = bundle.getString("post_id");
String rootId = bundle.getString("root_id");
if (android.text.TextUtils.isEmpty(rootId)) rootId = postId;
try {
JSONObject body = new JSONObject();
body.put("channel_id", channelId);
body.put("message", message.toString());
body.put("root_id", rootId);
Network.INSTANCE.post(serverUrl, "/api/v4/posts?set_online=false", body,
null,
json -> {
if (json != null) {
try {
if (json.has("status_code")) {
int statusCode = json.getInt("status_code");
if (statusCode < 200 || statusCode >= 300) {
Log.i(TAG, "Reply FAILED status=" + statusCode);
onReplyFailed(notificationId);
return null;
}
}
onReplySuccess(notificationId, message);
Log.i(TAG, "Reply SUCCESS");
} catch (JSONException e) {
e.printStackTrace();
onReplyFailed(notificationId);
}
} else {
Log.i(TAG, "Reply FAILED: null response");
onReplyFailed(notificationId);
}
return null;
},
e -> {
Log.i(TAG, "Reply FAILED exception: " + e.getMessage());
onReplyFailed(notificationId);
return null;
}
);
} catch (JSONException e) {
Log.e(TAG, "JSON error: " + e.getMessage());
onReplyFailed(notificationId);
}
}
protected void onReplyFailed(int notificationId) {
recreateNotification(notificationId, "Message failed to send.");
}
protected void onReplySuccess(int notificationId, final CharSequence message) {
recreateNotification(notificationId, message);
}
private void recreateNotification(int notificationId, final CharSequence message) {
Intent openIntent = LaunchIntentHelper.INSTANCE.forBundle(mContext, bundle);
PendingIntent pendingIntent = PendingIntent.getActivity(
mContext, notificationId, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder builder =
CustomPushNotificationHelper.createNotificationBuilder(mContext, pendingIntent, bundle, false);
Notification notification = builder.build();
NotificationCompat.MessagingStyle messagingStyle =
NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(notification);
if (messagingStyle != null) {
messagingStyle.addMessage(message, System.currentTimeMillis(), (Person) null);
notification = builder.setStyle(messagingStyle).build();
}
notificationManager.notify(notificationId, notification);
}
private CharSequence getReplyMessage(Intent intent) {
Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
if (remoteInput != null) {
return remoteInput.getCharSequence(CustomPushNotificationHelper.KEY_TEXT_REPLY);
}
return null;
}
}

View file

@ -0,0 +1,60 @@
package com.tokilabs.mattermost_push_plugin;
import android.os.Bundle;
import android.util.Log;
import com.tokilabs.mattermost_push_plugin.helpers.Network;
import org.json.JSONObject;
import java.util.Objects;
import okhttp3.Response;
public class ReceiptDelivery {
private static final String TAG = "ReceiptDelivery";
private static final String[] ACK_KEYS = {
"post_id", "root_id", "category", "message", "team_id",
"channel_id", "channel_name", "type", "sender_id", "sender_name", "version"
};
public static Bundle send(final String ackId, final String serverUrl,
final String postId, final String type, final boolean isIdLoaded) {
Log.i(TAG, String.format("Send ACK=%s TYPE=%s to URL=%s ID-LOADED=%s", ackId, type, serverUrl, isIdLoaded));
try {
JSONObject body = new JSONObject();
body.put("id", ackId);
body.put("received_at", System.currentTimeMillis());
body.put("platform", "android");
body.put("type", type);
body.put("post_id", postId);
body.put("is_id_loaded", isIdLoaded);
try (Response response = Network.INSTANCE.postSync(serverUrl, "api/v4/notifications/ack", body, null)) {
String responseBody = Objects.requireNonNull(response.body()).string();
JSONObject jsonResponse = new JSONObject(responseBody);
return parseAckResponse(jsonResponse);
}
} catch (Exception e) {
Log.e(TAG, "Send receipt delivery failed: " + e.getMessage());
e.printStackTrace();
return null;
}
}
public static Bundle parseAckResponse(JSONObject jsonResponse) {
try {
Bundle bundle = new Bundle();
for (String key : ACK_KEYS) {
if (jsonResponse.has(key)) {
bundle.putString(key, jsonResponse.getString(key));
}
}
return bundle;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}

View file

@ -0,0 +1,510 @@
package com.tokilabs.mattermost_push_plugin.helpers;
import android.annotation.SuppressLint;
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.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.Person;
import androidx.core.app.RemoteInput;
import androidx.core.graphics.drawable.IconCompat;
import com.tokilabs.mattermost_push_plugin.NotificationDismissService;
import com.tokilabs.mattermost_push_plugin.NotificationReplyBroadcastReceiver;
import com.tokilabs.mattermost_push_plugin.R;
import com.tokilabs.mattermost_push_plugin.helpers.db.MattermostDatabase;
import java.io.IOException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Date;
import java.util.Objects;
import io.jsonwebtoken.IncorrectClaimException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MissingClaimException;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
public class CustomPushNotificationHelper {
public static final String CHANNEL_HIGH_IMPORTANCE_ID = "channel_01";
public static final String CHANNEL_MIN_IMPORTANCE_ID = "channel_02";
public static final String KEY_TEXT_REPLY = "CAN_REPLY";
public static final String NOTIFICATION_ID = "notificationId";
public static final String NOTIFICATION = "notification";
public static final String PUSH_TYPE_MESSAGE = "message";
public static final String PUSH_TYPE_CLEAR = "clear";
public static final String PUSH_TYPE_SESSION = "session";
public static final String CATEGORY_CAN_REPLY = "CAN_REPLY";
public static final String SIGNING_PREFS = "mattermost_signing";
private static final String TAG = "PushNotifHelper";
private static NotificationChannel mHighImportanceChannel;
private static NotificationChannel mMinImportanceChannel;
private static final OkHttpClient client = new OkHttpClient();
private static final BitmapCache bitmapCache = new BitmapCache();
public static NotificationCompat.Builder createNotificationBuilder(
Context context, PendingIntent intent, Bundle bundle, boolean createSummary) {
final NotificationCompat.Builder notification =
new NotificationCompat.Builder(context, CHANNEL_HIGH_IMPORTANCE_ID);
String postId = bundle.getString("post_id");
String rootId = bundle.getString("root_id");
String channelId = bundle.getString("channel_id");
int notificationId = postId != null ? postId.hashCode() : NotificationHelper.MESSAGE_NOTIFICATION_ID;
boolean isCrtEnabled = bundle.containsKey("is_crt_enabled") &&
Objects.equals(bundle.getString("is_crt_enabled"), "true");
String groupId = isCrtEnabled && !TextUtils.isEmpty(rootId) ? rootId : channelId;
addNotificationExtras(notification, bundle);
setNotificationIcons(context, notification, bundle);
setNotificationMessagingStyle(context, notification, bundle);
setNotificationGroup(notification, groupId, createSummary);
setNotificationBadgeType(notification);
setNotificationChannel(context, notification);
setNotificationDeleteIntent(context, notification, bundle, notificationId);
addNotificationReplyAction(context, notification, bundle, notificationId);
notification
.setContentIntent(intent)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPriority(Notification.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true);
return notification;
}
public static void createNotificationChannels(Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
if (mHighImportanceChannel == null) {
mHighImportanceChannel = new NotificationChannel(
CHANNEL_HIGH_IMPORTANCE_ID, "High Importance", NotificationManager.IMPORTANCE_HIGH);
mHighImportanceChannel.setShowBadge(true);
notificationManager.createNotificationChannel(mHighImportanceChannel);
}
if (mMinImportanceChannel == null) {
mMinImportanceChannel = new NotificationChannel(
CHANNEL_MIN_IMPORTANCE_ID, "Min Importance", NotificationManager.IMPORTANCE_MIN);
mMinImportanceChannel.setShowBadge(true);
notificationManager.createNotificationChannel(mMinImportanceChannel);
}
}
public static boolean verifySignature(final Context context, String signature,
String serverUrl, String ackId) {
if (signature == null) {
Log.i(TAG, "No signature in the notification (backward compat)");
return true;
}
if (serverUrl == null) {
Log.i(TAG, "No server_url available, skipping signature check");
return true;
}
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
if (dbHelper == null) {
Log.i(TAG, "Cannot access the database, skipping signature check");
return true;
}
MattermostDatabase db = dbHelper.getDatabaseForServer(context, serverUrl);
if (db == null) {
Log.i(TAG, "Cannot access the server database, skipping signature check");
return true;
}
if ("NO_SIGNATURE".equals(signature)) {
String version = db.queryConfigServerVersion();
if (version == null) {
Log.i(TAG, "No server version");
return false;
}
if (!version.matches("[0-9]+(\\.[0-9]+)*")) {
Log.i(TAG, "Invalid server version");
return false;
}
String[] parts = version.split("\\.");
int major = parts.length > 0 ? Integer.parseInt(parts[0]) : 0;
int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
int patch = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
int[][] targets = {{9,8,0},{9,7,3},{9,6,3},{9,5,5},{8,1,14}};
boolean rejected = false;
for (int i = 0; i < targets.length; i++) {
boolean first = i == 0;
int majorTarget = targets[i][0];
int minorTarget = targets[i][1];
int patchTarget = targets[i][2];
if (major > majorTarget) { rejected = first; break; }
if (major < majorTarget) { continue; }
if (minor > minorTarget) { rejected = first; break; }
if (minor < minorTarget) { continue; }
if (patch >= patchTarget) { rejected = true; break; }
return true;
}
if (rejected) {
Log.i(TAG, "Server version should send signature");
return false;
}
return true;
}
String signingKey = db.queryConfigSigningKey();
if (signingKey == null) {
signingKey = context.getSharedPreferences(SIGNING_PREFS, Context.MODE_PRIVATE)
.getString("signing_key_" + serverUrl, null);
}
if (signingKey == null) {
Log.i(TAG, "No signing key, skipping signature check");
return true;
}
try {
byte[] encoded = Base64.decode(signingKey, 0);
KeyFactory kf = KeyFactory.getInstance("EC");
PublicKey pubKey = kf.generatePublic(new X509EncodedKeySpec(encoded));
String storedDeviceToken = dbHelper.getDeviceToken();
if (storedDeviceToken == null) {
Log.i(TAG, "No device token stored");
return false;
}
String[] tokenParts = storedDeviceToken.split(":", 2);
if (tokenParts.length != 2) {
Log.i(TAG, "Wrong stored device token format");
return false;
}
String deviceToken = tokenParts[1];
if (deviceToken.isEmpty()) {
Log.i(TAG, "Empty stored device token");
return false;
}
Jwts.parser()
.require("ack_id", ackId)
.require("device_id", deviceToken)
.verifyWith((PublicKey) pubKey)
.build()
.parseSignedClaims(signature);
} catch (MissingClaimException e) {
Log.i(TAG, "Missing claim: " + e.getMessage());
return false;
} catch (IncorrectClaimException e) {
Log.i(TAG, "Incorrect claim: " + e.getMessage());
return false;
} catch (JwtException e) {
Log.i(TAG, "Cannot verify JWT: " + e.getMessage());
return false;
} catch (Exception e) {
Log.i(TAG, "Exception while parsing JWT: " + e.getMessage());
return false;
}
return true;
}
private static void addNotificationExtras(NotificationCompat.Builder notification, Bundle bundle) {
Bundle userInfoBundle = bundle.getBundle("userInfo");
if (userInfoBundle == null) userInfoBundle = new Bundle();
putIfNotNull(userInfoBundle, "channel_id", bundle.getString("channel_id"));
putIfNotNull(userInfoBundle, "post_id", bundle.getString("post_id"));
putIfNotNull(userInfoBundle, "root_id", bundle.getString("root_id"));
putIfNotNull(userInfoBundle, "is_crt_enabled", bundle.getString("is_crt_enabled"));
putIfNotNull(userInfoBundle, "server_url", bundle.getString("server_url"));
notification.addExtras(userInfoBundle);
}
@SuppressLint("UnspecifiedImmutableFlag")
private static void addNotificationReplyAction(Context context, NotificationCompat.Builder notification,
Bundle bundle, int notificationId) {
String postId = bundle.getString("post_id");
String serverUrl = bundle.getString("server_url");
boolean canReply = bundle.containsKey("category") &&
Objects.equals(bundle.getString("category"), CATEGORY_CAN_REPLY);
if (TextUtils.isEmpty(postId) || serverUrl == null || !canReply) return;
Intent replyIntent = new Intent(context, NotificationReplyBroadcastReceiver.class);
replyIntent.setAction(KEY_TEXT_REPLY);
replyIntent.putExtra(NOTIFICATION_ID, notificationId);
replyIntent.putExtra(NOTIFICATION, bundle);
PendingIntent replyPendingIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
replyPendingIntent = PendingIntent.getBroadcast(context, notificationId, replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
} else {
replyPendingIntent = PendingIntent.getBroadcast(context, notificationId, replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT);
}
RemoteInput remoteInput = new RemoteInput.Builder(KEY_TEXT_REPLY).setLabel("Reply").build();
NotificationCompat.Action replyAction = new NotificationCompat.Action.Builder(
R.drawable.ic_notif_action_reply, "Reply", replyPendingIntent)
.addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.build();
notification.setShowWhen(true).addAction(replyAction);
}
private static void setNotificationMessagingStyle(Context context,
NotificationCompat.Builder notification, Bundle bundle) {
notification.setStyle(getMessagingStyle(context, bundle));
}
private static NotificationCompat.MessagingStyle getMessagingStyle(Context context, Bundle bundle) {
final String serverUrl = bundle.getString("server_url");
final String type = bundle.getString("type");
String urlOverride = bundle.getString("override_icon_url");
Person.Builder sender = new Person.Builder().setKey("me").setName("Me");
if (serverUrl != null && type != null && !type.equals(PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(context, serverUrl, "me", urlOverride);
if (avatar != null) sender.setIcon(IconCompat.createWithBitmap(avatar));
} catch (IOException e) {
e.printStackTrace();
}
}
NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle(sender.build());
String conversationTitle = getConversationTitle(bundle);
setMessagingStyleConversationTitle(messagingStyle, conversationTitle, bundle);
addMessagingStyleMessages(context, messagingStyle, conversationTitle, bundle);
return messagingStyle;
}
private static void addMessagingStyleMessages(Context context,
NotificationCompat.MessagingStyle messagingStyle,
String conversationTitle, Bundle bundle) {
String message = bundle.getString("message", bundle.getString("body"));
String senderId = bundle.getString("sender_id");
final String serverUrl = bundle.getString("server_url");
final String type = bundle.getString("type");
String urlOverride = bundle.getString("override_icon_url");
if (senderId == null) senderId = "sender_id";
String senderName = getSenderName(bundle);
if (conversationTitle == null || !TextUtils.isEmpty(senderName.trim())) {
message = removeSenderNameFromMessage(message, senderName);
}
long timestamp = new Date().getTime();
Person.Builder sender = new Person.Builder().setKey(senderId).setName(senderName);
if (serverUrl != null && type != null && !type.equals(PUSH_TYPE_SESSION)) {
try {
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
if (avatar != null) sender.setIcon(IconCompat.createWithBitmap(avatar));
} catch (IOException e) {
e.printStackTrace();
}
}
messagingStyle.addMessage(message, timestamp, sender.build());
}
private static void setNotificationIcons(Context context, NotificationCompat.Builder notification, Bundle bundle) {
String channelName = getConversationTitle(bundle);
String senderName = bundle.getString("sender_name");
String serverUrl = bundle.getString("server_url");
String urlOverride = bundle.getString("override_icon_url");
notification.setSmallIcon(R.mipmap.ic_notification);
if (serverUrl != null && channelName.equals(senderName)) {
try {
String senderId = bundle.getString("sender_id");
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
if (avatar != null) notification.setLargeIcon(avatar);
} catch (IOException e) {
e.printStackTrace();
}
}
}
private static void setNotificationGroup(NotificationCompat.Builder notification,
String channelId, boolean setAsSummary) {
notification.setGroup(channelId);
if (setAsSummary) notification.setGroupSummary(true);
}
private static void setNotificationBadgeType(NotificationCompat.Builder notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notification.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE);
}
}
private static void setNotificationChannel(Context context, NotificationCompat.Builder notification) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
if (mHighImportanceChannel == null) createNotificationChannels(context);
notification.setChannelId(mHighImportanceChannel.getId());
}
@SuppressLint("UnspecifiedImmutableFlag")
private static void setNotificationDeleteIntent(Context context, NotificationCompat.Builder notification,
Bundle bundle, int notificationId) {
final String PUSH_NOTIFICATION_EXTRA_NAME = "pushNotification";
Intent delIntent = new Intent(context, NotificationDismissService.class);
delIntent.putExtra(NOTIFICATION_ID, notificationId);
delIntent.putExtra(PUSH_NOTIFICATION_EXTRA_NAME, bundle);
PendingIntent deleteIntent = PendingIntent.getService(context,
(int) System.currentTimeMillis(), delIntent,
PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);
notification.setDeleteIntent(deleteIntent);
}
private static void setMessagingStyleConversationTitle(
NotificationCompat.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) {
String channelName = getConversationTitle(bundle);
String senderName = bundle.getString("sender_name");
if (TextUtils.isEmpty(senderName)) senderName = getSenderName(bundle);
if (conversationTitle != null && !channelName.equals(senderName)) {
messagingStyle.setConversationTitle(conversationTitle);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
messagingStyle.setGroupConversation(true);
}
}
}
private static String getConversationTitle(Bundle bundle) {
String title = bundle.getString("channel_name");
if (TextUtils.isEmpty(title)) title = bundle.getString("sender_name");
if (TextUtils.isEmpty(title)) title = bundle.getString("title", "");
return title;
}
private static String getSenderName(Bundle bundle) {
String senderName = bundle.getString("sender_name");
if (senderName != null) return senderName;
String channelName = bundle.getString("channel_name");
if (channelName != null && channelName.startsWith("@")) return channelName;
String message = bundle.getString("message");
if (message != null) {
String name = message.split(":")[0];
if (!name.equals(message)) return name;
}
return getConversationTitle(bundle);
}
private static String removeSenderNameFromMessage(String message, String senderName) {
int index = message.indexOf(senderName);
if (index == 0) message = message.substring(senderName.length());
return message.replaceFirst(": ", "").trim();
}
private static Bitmap getCircleBitmap(Bitmap bitmap) {
final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888);
final Canvas canvas = new Canvas(output);
final Paint paint = new Paint();
final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight());
final RectF rectF = new RectF(rect);
paint.setAntiAlias(true);
canvas.drawARGB(0, 0, 0, 0);
paint.setColor(Color.RED);
canvas.drawOval(rectF, paint);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
canvas.drawBitmap(bitmap, rect, rect, paint);
bitmap.recycle();
return output;
}
private static Bitmap userAvatar(final Context context, @NonNull final String serverUrl,
final String userId, final String urlOverride) throws IOException {
try {
Response response;
Double lastUpdateAt = 0.0;
if (!TextUtils.isEmpty(urlOverride)) {
Request request = new Request.Builder().url(urlOverride).build();
response = client.newCall(request).execute();
} else {
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
if (dbHelper != null) {
MattermostDatabase db = dbHelper.getDatabaseForServer(context, serverUrl);
if (db != null) {
lastUpdateAt = db.getLastPictureUpdate(userId);
if (lastUpdateAt == null) lastUpdateAt = 0.0;
}
}
Bitmap cached = bitmapCache.bitmap(userId, lastUpdateAt, serverUrl);
if (cached != null) {
return getCircleBitmap(cached.copy(cached.getConfig(), false));
}
bitmapCache.removeBitmap(userId, serverUrl);
response = Network.INSTANCE.getSync(serverUrl, "api/v4/users/" + userId + "/image", null);
}
if (response.code() == 200 && response.body() != null) {
byte[] bytes = response.body().bytes();
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (TextUtils.isEmpty(urlOverride) && !TextUtils.isEmpty(userId)) {
bitmapCache.insertBitmap(bitmap.copy(bitmap.getConfig(), false), userId, lastUpdateAt, serverUrl);
}
return getCircleBitmap(bitmap);
}
return null;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
private static void putIfNotNull(Bundle bundle, String key, String value) {
if (value != null) bundle.putString(key, value);
}
}

View file

@ -0,0 +1,31 @@
package com.tokilabs.mattermost_push_plugin
import android.app.Activity
import android.app.Application
import android.os.Bundle
object AppLifecycleTracker : Application.ActivityLifecycleCallbacks {
var isInForeground = false
private set
private var activeActivities = 0
override fun onActivityStarted(activity: Activity) {
activeActivities++
isInForeground = true
}
override fun onActivityStopped(activity: Activity) {
activeActivities--
if (activeActivities <= 0) {
activeActivities = 0
isInForeground = false
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
}

View file

@ -0,0 +1,15 @@
package com.tokilabs.mattermost_push_plugin
import android.content.Context
import android.content.Intent
import android.os.Bundle
object LaunchIntentHelper {
fun forBundle(context: Context, bundle: Bundle): Intent {
val base = context.packageManager.getLaunchIntentForPackage(context.packageName)
?: Intent(Intent.ACTION_MAIN).apply { setPackage(context.packageName) }
base.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
base.putExtras(bundle)
return base
}
}

View file

@ -0,0 +1,219 @@
package com.tokilabs.mattermost_push_plugin
import android.app.PendingIntent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.tokilabs.mattermost_push_plugin.helpers.CustomPushNotificationHelper
import com.tokilabs.mattermost_push_plugin.helpers.DatabaseHelper
import com.tokilabs.mattermost_push_plugin.helpers.NotificationHelper
import com.tokilabs.mattermost_push_plugin.helpers.PushNotificationDataHelper
import io.flutter.embedding.engine.FlutterEngineCache
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class MattermostFirebaseMessagingService : FirebaseMessagingService() {
private val dataHelper by lazy { PushNotificationDataHelper(this) }
companion object {
private const val TAG = "MattermostFCM"
}
override fun onCreate() {
super.onCreate()
// Service can be started before plugin attaches; init DB defensively.
DatabaseHelper.init(applicationContext)
}
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.i(TAG, "New FCM token: $token")
Handler(Looper.getMainLooper()).post {
PushNotificationEvents.eventSink?.success(
mapOf("type" to "token_refresh", "token" to token),
)
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
Log.i(TAG, ">>> onMessageReceived ENTER, dataSize=${remoteMessage.data.size}")
try {
val data = remoteMessage.data
if (data.isEmpty()) return
val bundle = Bundle().apply {
data.forEach { (k, v) -> putString(k, v) }
}
val type = bundle.getString("type")
val ackId = bundle.getString("ack_id")
val postId = bundle.getString("post_id")
val channelId = bundle.getString("channel_id")
val signature = bundle.getString("signature")
val isIdLoaded = bundle.getString("id_loaded") == "true"
val notificationId = try {
NotificationHelper.getNotificationId(bundle)
} catch (e: Exception) {
Log.e(TAG, "getNotificationId failed: ${e.message}")
System.currentTimeMillis().toInt()
}
var serverUrl = bundle.getString("server_url")
if (serverUrl == null) {
try {
val dbHelper = DatabaseHelper.getInstance()
val serverId = bundle.getString("server_id")
if (serverId != null && dbHelper != null) {
serverUrl = dbHelper.getServerUrlForIdentifier(serverId)
}
if (serverUrl == null) {
serverUrl = dbHelper?.onlyServerUrl
}
} catch (e: Exception) {
Log.e(TAG, "DB lookup failed: ${e.message}")
}
if (serverUrl != null) {
bundle.putString("server_url", serverUrl)
}
}
Log.i(TAG, "onMessageReceived type=$type channelId=$channelId ackId=$ackId serverUrl=$serverUrl")
GlobalScope.launch {
try {
handlePushNotificationInCoroutine(
serverUrl, type, channelId, ackId,
isIdLoaded, notificationId, postId, signature, bundle,
)
} catch (e: Exception) {
Log.e(TAG, "Error handling notification: ${e.message}")
e.printStackTrace()
}
}
} catch (e: Exception) {
Log.e(TAG, ">>> onMessageReceived CRASH: ${e.message}")
e.printStackTrace()
}
}
private suspend fun handlePushNotificationInCoroutine(
serverUrl: String?,
type: String?,
channelId: String?,
ackId: String?,
isIdLoaded: Boolean,
notificationId: Int,
postId: String?,
signature: String?,
bundle: Bundle,
) {
val currentBundle = bundle
if (ackId != null && serverUrl != null) {
val response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded)
if (isIdLoaded && response != null) {
if (!currentBundle.containsKey("server_url")) {
response.putString("server_url", serverUrl)
}
currentBundle.putAll(response)
}
}
if (!CustomPushNotificationHelper.verifySignature(this, signature, serverUrl, ackId)) {
Log.i(TAG, "Notification skipped: signature verification failed")
return
}
finishProcessingNotification(serverUrl, type, channelId, notificationId, currentBundle)
}
private suspend fun finishProcessingNotification(
serverUrl: String?,
type: String?,
channelId: String?,
notificationId: Int,
bundle: Bundle,
) {
val currentBundle = bundle
val isFlutterRunning = isFlutterEngineRunning()
when (type) {
CustomPushNotificationHelper.PUSH_TYPE_MESSAGE,
CustomPushNotificationHelper.PUSH_TYPE_SESSION,
-> {
if (!isFlutterRunning || !isAppInForeground()) {
var createSummary = type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE
if (type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE && channelId != null) {
serverUrl?.let {
val notificationResult = dataHelper.fetchAndStoreDataForPushNotification(currentBundle, isFlutterRunning)
notificationResult?.let { result ->
currentBundle.putBundle("data", result)
}
}
createSummary = NotificationHelper.addNotificationToPreferences(this, notificationId, currentBundle)
}
buildAndPostNotification(notificationId, createSummary, currentBundle)
}
}
CustomPushNotificationHelper.PUSH_TYPE_CLEAR ->
NotificationHelper.clearChannelOrThreadNotifications(this, currentBundle)
}
if (isFlutterRunning) {
notifyReceivedToFlutter(currentBundle)
}
}
private fun notifyReceivedToFlutter(bundle: Bundle) {
val data = bundleToMap(bundle)
Log.i(TAG, "Sending notification event to Flutter: type=${data["type"]}")
PushNotificationEvents.send(data)
}
private fun buildAndPostNotification(notificationId: Int, createSummary: Boolean, bundle: Bundle) {
val intent = LaunchIntentHelper.forBundle(this, bundle)
val pendingIntent = PendingIntent.getActivity(
this, notificationId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = CustomPushNotificationHelper.createNotificationBuilder(this, pendingIntent, bundle, false).build()
if (createSummary) {
val summary = CustomPushNotificationHelper.createNotificationBuilder(this, pendingIntent, bundle, true).build()
NotificationHelper.postNotification(this, summary, notificationId + 1)
}
NotificationHelper.postNotification(this, notification, notificationId)
}
private fun isFlutterEngineRunning(): Boolean {
if (PushNotificationEvents.isFlutterAttached) return true
return FlutterEngineCache.getInstance().contains(MattermostPushPlugin.FLUTTER_ENGINE_ID)
}
private fun isAppInForeground(): Boolean {
return AppLifecycleTracker.isInForeground
}
private fun bundleToMap(bundle: Bundle): Map<String, Any?> {
val map = mutableMapOf<String, Any?>()
for (key in bundle.keySet()) {
val value = bundle.get(key)
map[key] = when (value) {
is Bundle -> bundleToMap(value)
else -> value
}
}
return map
}
}

View file

@ -0,0 +1,191 @@
package com.tokilabs.mattermost_push_plugin
import android.app.Application
import android.content.Context
import android.content.Intent
import com.tokilabs.mattermost_push_plugin.helpers.CustomPushNotificationHelper
import com.tokilabs.mattermost_push_plugin.helpers.DatabaseHelper
import com.tokilabs.mattermost_push_plugin.helpers.Network
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
class MattermostPushPlugin :
FlutterPlugin,
ActivityAware,
EventChannel.StreamHandler,
MethodCallHandler {
companion object {
const val CHANNEL_EVENTS = "com.tokilabs.mattermost/notifications"
const val CHANNEL_ACTIONS = "com.tokilabs.mattermost/notification_actions"
const val FLUTTER_ENGINE_ID = "main_engine"
const val SIGNING_PREFS = "mattermost_signing"
}
private var methodChannel: MethodChannel? = null
private var eventChannel: EventChannel? = null
private var applicationContext: Context? = null
private var lifecycleApplication: Application? = null
private var activityBinding: ActivityPluginBinding? = null
private val newIntentListener = PluginRegistry.NewIntentListener { intent ->
val payload = intentPayload(intent)
if (payload != null) {
PushNotificationEvents.send(payload)
}
false
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
val context = binding.applicationContext
applicationContext = context
DatabaseHelper.init(context)
CustomPushNotificationHelper.createNotificationChannels(context)
registerLifecycleCallbacks(context)
methodChannel = MethodChannel(binding.binaryMessenger, CHANNEL_ACTIONS).also {
it.setMethodCallHandler(this)
}
eventChannel = EventChannel(binding.binaryMessenger, CHANNEL_EVENTS).also {
it.setStreamHandler(this)
}
PushNotificationEvents.isFlutterAttached = true
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel?.setMethodCallHandler(null)
eventChannel?.setStreamHandler(null)
methodChannel = null
eventChannel = null
unregisterLifecycleCallbacks()
PushNotificationEvents.isFlutterAttached = false
PushNotificationEvents.setEventSink(null)
PushNotificationEvents.clearPending()
applicationContext = null
}
private fun registerLifecycleCallbacks(context: Context) {
val application = context.applicationContext as? Application ?: context as? Application ?: return
if (lifecycleApplication === application) return
lifecycleApplication?.unregisterActivityLifecycleCallbacks(AppLifecycleTracker)
application.registerActivityLifecycleCallbacks(AppLifecycleTracker)
lifecycleApplication = application
}
private fun unregisterLifecycleCallbacks() {
lifecycleApplication?.unregisterActivityLifecycleCallbacks(AppLifecycleTracker)
lifecycleApplication = null
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
PushNotificationEvents.setEventSink(events)
}
override fun onCancel(arguments: Any?) {
PushNotificationEvents.setEventSink(null)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addOnNewIntentListener(newIntentListener)
val payload = intentPayload(binding.activity.intent)
if (payload != null) {
PushNotificationEvents.send(payload)
}
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addOnNewIntentListener(newIntentListener)
}
override fun onDetachedFromActivity() {
activityBinding?.removeOnNewIntentListener(newIntentListener)
activityBinding = null
}
override fun onDetachedFromActivityForConfigChanges() {
activityBinding?.removeOnNewIntentListener(newIntentListener)
activityBinding = null
}
internal fun intentPayload(intent: Intent?): Map<String, Any?>? {
val extras = intent?.extras ?: return null
if (extras.isEmpty) return null
val data = mutableMapOf<String, Any?>()
for (key in extras.keySet()) {
data[key] = extras.get(key)
}
if (data.isEmpty()) return null
return data + mapOf(
"type" to (data["type"] ?: "opened"),
"userInteraction" to true,
)
}
override fun onMethodCall(call: MethodCall, result: Result) {
val context = applicationContext
when (call.method) {
"saveDeviceToken" -> {
val token = call.argument<String>("token")
if (token != null) {
DatabaseHelper.getInstance()?.saveDeviceToken(token)
result.success(null)
} else {
result.error("INVALID_ARG", "token is null", null)
}
}
"getDeviceToken" -> {
result.success(DatabaseHelper.getInstance()?.getDeviceToken())
}
"setAuthToken" -> {
val serverUrl = call.argument<String>("serverUrl")
val token = call.argument<String>("token")
val identifier = call.argument<String>("identifier")
if (serverUrl != null && token != null) {
Network.setToken(serverUrl, token)
DatabaseHelper.getInstance()?.saveServerUrl(serverUrl, identifier)
result.success(null)
} else {
result.error("INVALID_ARG", "serverUrl or token is null", null)
}
}
"setSigningKey" -> {
val serverUrl = call.argument<String>("serverUrl")
val signingKey = call.argument<String>("signingKey")
if (serverUrl != null && signingKey != null && context != null) {
context.getSharedPreferences(SIGNING_PREFS, Context.MODE_PRIVATE)
.edit()
.putString("signing_key_$serverUrl", signingKey)
.apply()
result.success(null)
} else {
result.error("INVALID_ARG", "serverUrl or signingKey is null", null)
}
}
"clearAuthToken" -> {
val serverUrl = call.argument<String>("serverUrl")
if (serverUrl != null) {
Network.clearToken(serverUrl)
result.success(null)
} else {
result.error("INVALID_ARG", "serverUrl is null", null)
}
}
"getPlatformVersion" -> {
result.success("Android ${android.os.Build.VERSION.RELEASE}")
}
else -> result.notImplemented()
}
}
}

View file

@ -0,0 +1,53 @@
package com.tokilabs.mattermost_push_plugin
import android.os.Handler
import android.os.Looper
import io.flutter.plugin.common.EventChannel
object PushNotificationEvents {
@Volatile
var isFlutterAttached: Boolean = false
private val mainHandler = Handler(Looper.getMainLooper())
private val pending = ArrayDeque<Map<String, Any?>>()
private val lock = Any()
@Volatile
private var sink: EventChannel.EventSink? = null
val eventSink: EventChannel.EventSink?
get() = sink
fun setEventSink(newSink: EventChannel.EventSink?) {
val drain: List<Map<String, Any?>>
synchronized(lock) {
sink = newSink
if (newSink == null) {
return
}
drain = pending.toList()
pending.clear()
}
for (data in drain) {
mainHandler.post { newSink?.success(data) }
}
}
fun send(data: Map<String, Any?>) {
val current: EventChannel.EventSink?
synchronized(lock) {
current = sink
if (current == null) {
pending.addLast(data)
return
}
}
mainHandler.post { current?.success(data) }
}
fun clearPending() {
synchronized(lock) {
pending.clear()
}
}
}

View file

@ -0,0 +1,24 @@
package com.tokilabs.mattermost_push_plugin.helpers
import android.graphics.Bitmap
import android.util.LruCache
class BitmapCache {
private data class CacheKey(val userId: String, val serverUrl: String)
private data class CacheEntry(val bitmap: Bitmap, val lastUpdateAt: Double)
private val cache = LruCache<CacheKey, CacheEntry>(20)
fun bitmap(userId: String, lastUpdateAt: Double, serverUrl: String): Bitmap? {
val entry = cache.get(CacheKey(userId, serverUrl)) ?: return null
return if (entry.lastUpdateAt == lastUpdateAt) entry.bitmap else null
}
fun insertBitmap(bitmap: Bitmap, userId: String, lastUpdateAt: Double, serverUrl: String) {
cache.put(CacheKey(userId, serverUrl), CacheEntry(bitmap, lastUpdateAt))
}
fun removeBitmap(userId: String, serverUrl: String) {
cache.remove(CacheKey(userId, serverUrl))
}
}

View file

@ -0,0 +1,83 @@
package com.tokilabs.mattermost_push_plugin.helpers
import android.content.Context
import android.util.Log
import com.tokilabs.mattermost_push_plugin.helpers.db.GlobalDatabase
import com.tokilabs.mattermost_push_plugin.helpers.db.GlobalEntity
import com.tokilabs.mattermost_push_plugin.helpers.db.MattermostDatabase
import com.tokilabs.mattermost_push_plugin.helpers.db.ServerEntity
class DatabaseHelper private constructor(private val context: Context) {
companion object {
private const val TAG = "DatabaseHelper"
private const val DEVICE_TOKEN_KEY = "deviceToken"
@Volatile
private var instance: DatabaseHelper? = null
fun getInstance(): DatabaseHelper? = instance
fun init(context: Context): DatabaseHelper {
return instance ?: synchronized(this) {
instance ?: DatabaseHelper(context.applicationContext).also { instance = it }
}
}
}
private val globalDb by lazy { GlobalDatabase.getInstance(context) }
fun getDeviceToken(): String? {
return try {
globalDb.globalDao().getValue(DEVICE_TOKEN_KEY)
} catch (e: Exception) {
Log.e(TAG, "getDeviceToken error: ${e.message}")
null
}
}
fun saveDeviceToken(token: String) {
try {
globalDb.globalDao().upsert(GlobalEntity(DEVICE_TOKEN_KEY, token))
Log.i(TAG, "Device token saved")
} catch (e: Exception) {
Log.e(TAG, "saveDeviceToken error: ${e.message}")
}
}
val onlyServerUrl: String?
get() = try {
val servers = globalDb.serversDao().getAll()
if (servers.size == 1) servers.first().url else null
} catch (e: Exception) {
null
}
fun getServerUrlForIdentifier(serverId: String): String? {
return try {
globalDb.serversDao().getAll()
.firstOrNull { it.identifier == serverId }?.url
} catch (e: Exception) {
null
}
}
fun saveServerUrl(serverUrl: String, identifier: String? = null) {
try {
globalDb.serversDao().upsert(
ServerEntity(url = serverUrl, dbPath = "", identifier = identifier),
)
} catch (e: Exception) {
Log.e(TAG, "saveServerUrl error: ${e.message}")
}
}
fun getDatabaseForServer(context: Context, serverUrl: String): MattermostDatabase? {
return try {
MattermostDatabase.getInstance(context, serverUrl)
} catch (e: Exception) {
Log.e(TAG, "getDatabaseForServer error for $serverUrl: ${e.message}")
null
}
}
}

View file

@ -0,0 +1,103 @@
package com.tokilabs.mattermost_push_plugin.helpers
import android.util.Log
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import java.util.concurrent.TimeUnit
object Network {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private const val TAG = "NomadNetwork"
private val tokenCache = mutableMapOf<String, String>()
fun setToken(serverUrl: String, token: String) {
tokenCache[serverUrl] = token
}
fun clearToken(serverUrl: String) {
tokenCache.remove(serverUrl)
}
fun postSync(serverUrl: String, endpoint: String, body: JSONObject, headers: Map<String, String>? = null): Response {
val url = buildUrl(serverUrl, endpoint)
val requestBody = body.toString().toRequestBody(JSON_MEDIA_TYPE)
val requestBuilder = Request.Builder()
.url(url)
.post(requestBody)
.addHeader("Content-Type", "application/json")
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
headers?.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
Log.i(TAG, "POST $url")
return client.newCall(requestBuilder.build()).execute()
}
fun post(
serverUrl: String,
endpoint: String,
body: JSONObject,
headers: Map<String, String>? = null,
onSuccess: (JSONObject?) -> Unit,
onFailure: (Exception) -> Unit,
) {
val url = buildUrl(serverUrl, endpoint)
val requestBody = body.toString().toRequestBody(JSON_MEDIA_TYPE)
val requestBuilder = Request.Builder()
.url(url)
.post(requestBody)
.addHeader("Content-Type", "application/json")
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
headers?.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
Log.i(TAG, "POST async $url")
client.newCall(requestBuilder.build()).enqueue(object : okhttp3.Callback {
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
Log.e(TAG, "POST failed: ${e.message}")
onFailure(e)
}
override fun onResponse(call: okhttp3.Call, response: Response) {
try {
val responseBody = response.body?.string()
val json = if (!responseBody.isNullOrEmpty()) JSONObject(responseBody) else null
onSuccess(json)
} catch (e: Exception) {
Log.e(TAG, "POST response parse error: ${e.message}")
onSuccess(null)
}
}
})
}
fun getSync(serverUrl: String, endpoint: String, headers: Map<String, String>? = null): Response {
val url = buildUrl(serverUrl, endpoint)
val requestBuilder = Request.Builder()
.url(url)
.get()
tokenCache[serverUrl]?.let { requestBuilder.addHeader("Authorization", "Bearer $it") }
headers?.forEach { (k, v) -> requestBuilder.addHeader(k, v) }
Log.i(TAG, "GET $url")
return client.newCall(requestBuilder.build()).execute()
}
private fun buildUrl(serverUrl: String, endpoint: String): String {
val base = serverUrl.trimEnd('/')
val path = endpoint.trimStart('/')
return "$base/$path"
}
}

View file

@ -0,0 +1,75 @@
package com.tokilabs.mattermost_push_plugin.helpers
import android.app.NotificationManager
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.core.app.NotificationManagerCompat
object NotificationHelper {
private const val TAG = "NotificationHelper"
const val MESSAGE_NOTIFICATION_ID = -1
private val notificationPreferences = mutableMapOf<Int, Bundle>()
fun getNotificationId(bundle: Bundle): Int {
val postId = bundle.getString("post_id")
return postId?.hashCode() ?: MESSAGE_NOTIFICATION_ID
}
fun addNotificationToPreferences(context: Context, notificationId: Int, bundle: Bundle): Boolean {
val channelId = bundle.getString("channel_id") ?: return true
val rootId = bundle.getString("root_id")
val isCRTEnabled = bundle.getString("is_crt_enabled") == "true"
val groupId = if (isCRTEnabled && !rootId.isNullOrEmpty()) rootId else channelId
val isFirst = notificationPreferences.none { (_, b) ->
val bGroupId = if (isCRTEnabled && !b.getString("root_id").isNullOrEmpty())
b.getString("root_id") else b.getString("channel_id")
bGroupId == groupId
}
notificationPreferences[notificationId] = bundle
return isFirst
}
fun clearChannelOrThreadNotifications(context: Context, bundle: Bundle) {
val channelId = bundle.getString("channel_id") ?: return
val rootId = bundle.getString("root_id")
val isCRTEnabled = bundle.getString("is_crt_enabled") == "true"
val targetGroupId = if (isCRTEnabled && !rootId.isNullOrEmpty()) rootId else channelId
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val toRemove = mutableListOf<Int>()
notificationPreferences.forEach { (id, b) ->
val bRootId = b.getString("root_id")
val bChannelId = b.getString("channel_id")
val bGroupId = if (isCRTEnabled && !bRootId.isNullOrEmpty()) bRootId else bChannelId
if (bGroupId == targetGroupId) {
notificationManager.cancel(id)
toRemove.add(id)
Log.i(TAG, "Cancelled notification id=$id for group=$targetGroupId")
}
}
toRemove.forEach { notificationPreferences.remove(it) }
}
fun dismissNotification(context: Context, bundle: Bundle) {
val notificationId = getNotificationId(bundle)
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.cancel(notificationId)
notificationPreferences.remove(notificationId)
Log.i(TAG, "Dismissed notification id=$notificationId")
}
fun postNotification(context: Context, notification: android.app.Notification, notificationId: Int) {
try {
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.notify(notificationId, notification)
} catch (e: SecurityException) {
Log.e(TAG, "No notification permission: ${e.message}")
}
}
}

View file

@ -0,0 +1,182 @@
package com.tokilabs.mattermost_push_plugin.helpers
import android.content.Context
import android.os.Bundle
import android.util.Log
import com.tokilabs.mattermost_push_plugin.helpers.db.MattermostDatabase
import com.tokilabs.mattermost_push_plugin.helpers.db.SystemEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class PushNotificationDataHelper(private val context: Context) {
suspend fun fetchAndStoreDataForPushNotification(initialData: Bundle, isFlutterInit: Boolean): Bundle? {
return withContext(Dispatchers.Default) {
PushNotificationDataRunnable.start(context, initialData, isFlutterInit)
}
}
}
object PushNotificationDataRunnable {
private const val TAG = "PushNotifDataHelper"
private val mutex = Mutex()
suspend fun start(context: Context, initialData: Bundle, isFlutterInit: Boolean): Bundle? {
mutex.withLock {
val serverUrl = initialData.getString("server_url") ?: return null
val dbHelper = DatabaseHelper.getInstance() ?: run {
Log.e(TAG, "DatabaseHelper not initialized")
return null
}
val db = dbHelper.getDatabaseForServer(context, serverUrl)
var result: Bundle? = null
try {
if (db != null) {
val teamId = initialData.getString("team_id")
val channelId = initialData.getString("channel_id")
val postId = initialData.getString("post_id")
val rootId = initialData.getString("root_id")
val isCRTEnabled = initialData.getString("is_crt_enabled") == "true"
val ackId = initialData.getString("ack_id")
Log.i(TAG, "Start fetching notification data server=$serverUrl channel=$channelId ack=$ackId")
val notificationData = Bundle()
if (!teamId.isNullOrEmpty()) {
fetchTeamIfNeeded(db, serverUrl, teamId)?.let {
notificationData.putBundle("team", it)
}
}
if (channelId != null && postId != null) {
fetchMyChannel(db, channelId)?.let { notificationData.putBundle("channel", it) }
fetchPosts(db, serverUrl, channelId, isCRTEnabled, rootId)?.let {
notificationData.putBundle("posts", it)
}
if (isCRTEnabled && !rootId.isNullOrEmpty()) {
fetchThread(db, serverUrl, rootId)?.let {
notificationData.putBundle("thread", it)
}
}
fetchNeededUsers(serverUrl, channelId)?.let {
notificationData.putParcelableArray("users", it.toTypedArray())
}
}
result = notificationData
if (!isFlutterInit) {
saveToDatabase(db, notificationData, teamId, channelId, isCRTEnabled)
}
Log.i(TAG, "Done processing push notification server=$serverUrl channel=$channelId ack=$ackId")
}
} catch (e: Exception) {
Log.e(TAG, "Error processing push notification: ${e.message}")
e.printStackTrace()
} finally {
Log.i(TAG, "DONE fetching notification data")
}
return result
}
}
private fun fetchTeamIfNeeded(db: MattermostDatabase, serverUrl: String, teamId: String): Bundle? {
val team = db.teamDao().getTeam(teamId) ?: return null
return Bundle().apply {
putString("id", team.id)
putString("display_name", team.display_name)
}
}
private fun fetchMyChannel(db: MattermostDatabase, channelId: String): Bundle? {
val channel = db.channelDao().getChannel(channelId) ?: return null
return Bundle().apply {
putString("id", channel.id)
putString("display_name", channel.display_name)
putString("type", channel.type)
putString("team_id", channel.team_id)
}
}
private fun fetchPosts(
db: MattermostDatabase,
serverUrl: String,
channelId: String,
isCRTEnabled: Boolean,
rootId: String?,
): Bundle? {
val posts = if (isCRTEnabled && rootId != null) {
db.postDao().getThreadPosts(rootId)
} else {
db.postDao().getPostsForChannel(channelId)
}
if (posts.isEmpty()) return null
return Bundle().apply {
posts.forEachIndexed { i, post ->
putBundle("post_$i", Bundle().apply {
putString("id", post.id)
putString("message", post.message)
putString("user_id", post.user_id)
putLong("create_at", post.create_at)
})
}
}
}
private fun fetchThread(db: MattermostDatabase, serverUrl: String, rootId: String): Bundle? {
val posts = db.postDao().getThreadPosts(rootId)
if (posts.isEmpty()) return null
return Bundle().apply {
putInt("reply_count", posts.size)
}
}
private fun fetchNeededUsers(serverUrl: String, channelId: String): List<Bundle>? {
// [FOLLOW-UP] 현재 DB 스키마에는 `channel_members` 테이블이 존재하지 않습니다.
// 따라서 특정 채널의 멤버 유저들을 쿼리할 방법이 제한적이므로,
// 필요 유저 fetch 로직은 명시적 follow-up으로 분류하고 null을 반환합니다.
Log.i(TAG, "fetchNeededUsers: channelId=$channelId (No channel_members table in DB, classified as follow-up)")
return null
}
private fun saveToDatabase(
db: MattermostDatabase,
data: Bundle,
teamId: String?,
channelId: String?,
isCRTEnabled: Boolean,
) {
channelId ?: return
try {
val json = org.json.JSONObject().apply {
data.keySet()?.forEach { key ->
val value = data.get(key)
if (value is Bundle) {
// nested Bundle serialization
val subJson = org.json.JSONObject()
value.keySet()?.forEach { subKey ->
subJson.put(subKey, value.get(subKey))
}
put(key, subJson)
} else {
put(key, value)
}
}
}
db.systemDao().upsert(SystemEntity("pendingNotification_$channelId", json.toString()))
Log.i(TAG, "saveToDatabase: Successfully saved pendingNotification_$channelId to DB")
} catch (e: Exception) {
Log.e(TAG, "saveToDatabase error: ${e.message}")
}
}
}

View file

@ -0,0 +1,34 @@
package com.tokilabs.mattermost_push_plugin.helpers.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [GlobalEntity::class, ServerEntity::class],
version = 1,
exportSchema = false,
)
abstract class GlobalDatabase : RoomDatabase() {
abstract fun globalDao(): GlobalDao
abstract fun serversDao(): ServersDao
companion object {
@Volatile private var instance: GlobalDatabase? = null
fun getInstance(context: Context): GlobalDatabase {
return instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
GlobalDatabase::class.java,
"mattermost_global.db",
)
.allowMainThreadQueries()
.fallbackToDestructiveMigration()
.build()
.also { instance = it }
}
}
}
}

View file

@ -0,0 +1,49 @@
package com.tokilabs.mattermost_push_plugin.helpers.db
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
@Entity(tableName = "Global")
data class GlobalEntity(
@PrimaryKey val id: String,
val value: String,
)
@Dao
interface GlobalDao {
@Query("SELECT value FROM Global WHERE id = :id LIMIT 1")
fun getValue(id: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: GlobalEntity)
}
@Entity(tableName = "Servers")
data class ServerEntity(
@PrimaryKey val url: String,
@ColumnInfo(name = "db_path") val dbPath: String,
val identifier: String? = null,
)
@Dao
interface ServersDao {
@Query("SELECT * FROM Servers WHERE url = :url LIMIT 1")
fun getServer(url: String): ServerEntity?
@Query("SELECT COUNT(*) FROM Servers")
fun count(): Int
@Query("SELECT url FROM Servers LIMIT 1")
fun getFirstUrl(): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: ServerEntity)
@Query("SELECT * FROM Servers")
fun getAll(): List<ServerEntity>
}

View file

@ -0,0 +1,50 @@
package com.tokilabs.mattermost_push_plugin.helpers.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [
ConfigEntity::class, UserEntity::class, SystemEntity::class,
ChannelEntity::class, PostEntity::class, TeamEntity::class
],
version = 2,
exportSchema = false,
)
abstract class MattermostDatabase : RoomDatabase() {
abstract fun configDao(): ConfigDao
abstract fun userDao(): UserDao
abstract fun systemDao(): SystemDao
abstract fun channelDao(): ChannelDao
abstract fun postDao(): PostDao
abstract fun teamDao(): TeamDao
fun queryConfigServerVersion(): String? = configDao().getValue("Version")
fun queryConfigSigningKey(): String? = configDao().getValue("AsymmetricSigningPublicKey")
fun getLastPictureUpdate(userId: String): Double? {
val id = if (userId == "me") systemDao().getValue("currentUserId") ?: userId else userId
return userDao().getLastPictureUpdate(id)
}
companion object {
@Volatile private var instances = mutableMapOf<String, MattermostDatabase>()
fun getInstance(context: Context, serverUrl: String): MattermostDatabase {
return instances.getOrPut(serverUrl) {
val dbName = "mattermost_server_${serverUrl.hashCode()}.db"
Room.databaseBuilder(
context.applicationContext,
MattermostDatabase::class.java,
dbName,
)
.allowMainThreadQueries()
.fallbackToDestructiveMigration()
.build()
}
}
}
}

View file

@ -0,0 +1,128 @@
package com.tokilabs.mattermost_push_plugin.helpers.db
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
@Entity(tableName = "Config")
data class ConfigEntity(
@PrimaryKey val id: String,
val value: String?,
)
@Dao
interface ConfigDao {
@Query("SELECT value FROM Config WHERE id = :id LIMIT 1")
fun getValue(id: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: ConfigEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsertAll(entities: List<ConfigEntity>)
}
@Entity(tableName = "User")
data class UserEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "last_picture_update") val lastPictureUpdate: Double = 0.0,
val username: String? = null,
val email: String? = null,
@ColumnInfo(name = "first_name") val firstName: String? = null,
@ColumnInfo(name = "last_name") val lastName: String? = null,
val roles: String? = null,
)
@Dao
interface UserDao {
@Query("SELECT last_picture_update FROM User WHERE id = :userId LIMIT 1")
fun getLastPictureUpdate(userId: String): Double?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: UserEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsertAll(entities: List<UserEntity>)
}
@Entity(tableName = "System")
data class SystemEntity(
@PrimaryKey val id: String,
val value: String?,
)
@Dao
interface SystemDao {
@Query("SELECT value FROM System WHERE id = :id LIMIT 1")
fun getValue(id: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: SystemEntity)
}
// Channel 테이블
@Entity(tableName = "Channel")
data class ChannelEntity(
@PrimaryKey val id: String,
val display_name: String? = null,
val type: String? = null, // O=public, P=private, D=DM, G=Group
val team_id: String? = null,
val delete_at: Long = 0
)
@Dao
interface ChannelDao {
@Query("SELECT * FROM Channel WHERE id = :id LIMIT 1")
fun getChannel(id: String): ChannelEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: ChannelEntity)
}
// Post 테이블
@Entity(tableName = "Post")
data class PostEntity(
@PrimaryKey val id: String,
val channel_id: String? = null,
val root_id: String? = null,
val user_id: String? = null,
val message: String? = null,
val create_at: Long = 0,
val delete_at: Long = 0
)
@Dao
interface PostDao {
@Query("SELECT * FROM Post WHERE channel_id = :channelId ORDER BY create_at DESC LIMIT 20")
fun getPostsForChannel(channelId: String): List<PostEntity>
@Query("SELECT * FROM Post WHERE root_id = :rootId ORDER BY create_at ASC")
fun getThreadPosts(rootId: String): List<PostEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: PostEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsertAll(entities: List<PostEntity>)
}
// Team 테이블
@Entity(tableName = "Team")
data class TeamEntity(
@PrimaryKey val id: String,
val display_name: String? = null,
val name: String? = null
)
@Dao
interface TeamDao {
@Query("SELECT * FROM Team WHERE id = :id LIMIT 1")
fun getTeam(id: String): TeamEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsert(entity: TeamEntity)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,27 @@
package com.tokilabs.mattermost_push_plugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.mockito.Mockito
import kotlin.test.Test
/*
* This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation.
*
* Once you have built the plugin's example app, you can run these tests from the command
* line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or
* you can run them directly from IDEs that support JUnit such as Android Studio.
*/
internal class MattermostPushPluginTest {
@Test
fun onMethodCall_getPlatformVersion_returnsExpectedValue() {
val plugin = MattermostPushPlugin()
val call = MethodCall("getPlatformVersion", null)
val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java)
plugin.onMethodCall(call, mockResult)
Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE)
}
}

45
example/.gitignore vendored Normal file
View file

@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

17
example/README.md Normal file
View file

@ -0,0 +1,17 @@
# mattermost_push_plugin_example
Demonstrates how to use the mattermost_push_plugin plugin.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View file

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
example/android/.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View file

@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.tokilabs.mattermost_push_plugin_example"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.tokilabs.mattermost_push_plugin_example"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View file

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View file

@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="mattermost_push_plugin_example"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View file

@ -0,0 +1,5 @@
package com.tokilabs.mattermost_push_plugin_example
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View file

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View file

@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

View file

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View file

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

View file

@ -0,0 +1,14 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:mattermost_push_plugin/mattermost_push_plugin.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('singleton instance is accessible', (WidgetTester tester) async {
expect(MattermostPushPlugin.instance, isNotNull);
expect(MattermostPushPlugin.notificationChannelName,
'com.tokilabs.mattermost/notifications');
});
}

34
example/ios/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View file

@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View file

@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

43
example/ios/Podfile Normal file
View file

@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

View file

@ -0,0 +1,620 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tokilabs.mattermostPushPluginExample;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tokilabs.mattermostPushPluginExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tokilabs.mattermostPushPluginExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tokilabs.mattermostPushPluginExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tokilabs.mattermostPushPluginExample;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tokilabs.mattermostPushPluginExample;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View file

@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Some files were not shown because too many files have changed in this diff Show more