initial commit: mattermost-push-plugin
33
.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
||||||
|
## 0.0.1
|
||||||
|
|
||||||
|
* TODO: Describe initial release.
|
||||||
1
LICENSE
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
TODO: Add your license here.
|
||||||
135
README.md
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
||||||
|
rootProject.name = 'mattermost_push_plugin'
|
||||||
1
android/settings.gradle.kts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
rootProject.name = 'mattermost_push_plugin'
|
||||||
43
android/src/main/AndroidManifest.xml
Normal 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>
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
BIN
android/src/main/res/drawable-hdpi/ic_notif_action_reply.png
Normal file
|
After Width: | Height: | Size: 413 B |
BIN
android/src/main/res/drawable-mdpi/ic_notif_action_reply.png
Normal file
|
After Width: | Height: | Size: 348 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 348 B |
|
After Width: | Height: | Size: 610 B |
|
After Width: | Height: | Size: 833 B |
|
After Width: | Height: | Size: 1.2 KiB |
BIN
android/src/main/res/drawable-xhdpi/ic_notif_action_reply.png
Normal file
|
After Width: | Height: | Size: 610 B |
BIN
android/src/main/res/drawable-xxhdpi/ic_notif_action_reply.png
Normal file
|
After Width: | Height: | Size: 833 B |
BIN
android/src/main/res/drawable-xxxhdpi/ic_notif_action_reply.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
android/src/main/res/mipmap-hdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
android/src/main/res/mipmap-mdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 958 B |
BIN
android/src/main/res/mipmap-xhdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
android/src/main/res/mipmap-xxhdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
android/src/main/res/mipmap-xxxhdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
|
|
@ -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
|
|
@ -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
|
|
@ -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.
|
||||||
28
example/analysis_options.yaml
Normal 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
|
|
@ -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
|
||||||
44
example/android/app/build.gradle.kts
Normal 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 = "../.."
|
||||||
|
}
|
||||||
7
example/android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
45
example/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.tokilabs.mattermost_push_plugin_example
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
BIN
example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
example/android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||||
18
example/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
7
example/android/app/src/profile/AndroidManifest.xml
Normal 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>
|
||||||
24
example/android/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
6
example/android/gradle.properties
Normal 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
|
||||||
5
example/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||||
26
example/android/settings.gradle.kts
Normal 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")
|
||||||
14
example/integration_test/plugin_integration_test.dart
Normal 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
|
|
@ -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
|
||||||
24
example/ios/Flutter/AppFrameworkInfo.plist
Normal 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>
|
||||||
2
example/ios/Flutter/Debug.xcconfig
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
|
#include "Generated.xcconfig"
|
||||||
2
example/ios/Flutter/Release.xcconfig
Normal 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
|
|
@ -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
|
||||||
620
example/ios/Runner.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
7
example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
7
example/ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
16
example/ios/Runner/AppDelegate.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |