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 |