first commit
This commit is contained in:
56
lastochka-android-compose/tinodesdk/build.gradle.kts
Normal file
56
lastochka-android-compose/tinodesdk/build.gradle.kts
Normal file
@@ -0,0 +1,56 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "co.tinode.tinodesdk"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
|
||||
buildConfigField("String", "VERSION_NAME", "\"0.16.5\"")
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("com.google.code.gson:gson:2.11.0")
|
||||
implementation("com.google.firebase:firebase-messaging-ktx:24.1.0")
|
||||
|
||||
// Jackson (JSON serialization)
|
||||
implementation("com.fasterxml.jackson.core:jackson-core:2.17.2")
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2")
|
||||
implementation("com.fasterxml.jackson.core:jackson-annotations:2.17.2")
|
||||
|
||||
// Java-WebSocket (WebSocket client)
|
||||
implementation("org.java-websocket:Java-WebSocket:1.5.7")
|
||||
|
||||
// ICU4J (BreakIterator for grapheme cluster handling)
|
||||
implementation("com.ibm.icu:icu4j:75.1")
|
||||
}
|
||||
3
lastochka-android-compose/tinodesdk/consumer-rules.pro
Normal file
3
lastochka-android-compose/tinodesdk/consumer-rules.pro
Normal file
@@ -0,0 +1,3 @@
|
||||
# Keep Tinode SDK classes
|
||||
-keep class co.tinode.tinodesdk.** { *; }
|
||||
-keep class co.tinode.tinodesdk.model.** { *; }
|
||||
2
lastochka-android-compose/tinodesdk/proguard-rules.pro
vendored
Normal file
2
lastochka-android-compose/tinodesdk/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# tinodesdk proguard rules
|
||||
-keep class co.tinode.tinodesdk.** { *; }
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,7 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
/**
|
||||
* Thrown when the user is already subscribed to topic.
|
||||
*/
|
||||
public class AlreadySubscribedException extends IllegalStateException {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
/**
|
||||
* Thrown when the action requires authentication.
|
||||
*/
|
||||
public class AuthenticationRequiredException extends IllegalStateException {
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.ToIntFunction;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import co.tinode.tinodesdk.model.Description;
|
||||
import co.tinode.tinodesdk.model.Drafty;
|
||||
import co.tinode.tinodesdk.model.MetaSetDesc;
|
||||
import co.tinode.tinodesdk.model.MsgServerData;
|
||||
import co.tinode.tinodesdk.model.MsgServerMeta;
|
||||
import co.tinode.tinodesdk.model.MsgSetMeta;
|
||||
import co.tinode.tinodesdk.model.PrivateType;
|
||||
import co.tinode.tinodesdk.model.ServerMessage;
|
||||
import co.tinode.tinodesdk.model.Subscription;
|
||||
import co.tinode.tinodesdk.model.TheCard;
|
||||
|
||||
/**
|
||||
* Communication topic: a Slf, P2P or Group.
|
||||
*/
|
||||
public class ComTopic<DP extends TheCard> extends Topic<DP,PrivateType,DP,PrivateType> {
|
||||
public ComTopic(Tinode tinode, Subscription<DP,PrivateType> sub) {
|
||||
super(tinode, sub);
|
||||
}
|
||||
|
||||
public ComTopic(Tinode tinode, String name, Description<DP,PrivateType> desc) {
|
||||
super(tinode, name, desc);
|
||||
}
|
||||
|
||||
public ComTopic(Tinode tinode, String name, Listener<DP,PrivateType,DP,PrivateType> l) {
|
||||
super(tinode, name, l);
|
||||
}
|
||||
|
||||
public ComTopic(Tinode tinode, Listener l, boolean isChannel) {
|
||||
//noinspection unchecked
|
||||
super(tinode, l, isChannel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to topic.
|
||||
*/
|
||||
public PromisedReply<ServerMessage> subscribe() {
|
||||
if (isNew()) {
|
||||
MetaSetDesc<DP, PrivateType> desc = new MetaSetDesc<>(mDesc.pub, mDesc.priv);
|
||||
if (mDesc.pub != null) {
|
||||
desc.attachments = mDesc.pub.getPhotoRefs();
|
||||
}
|
||||
return subscribe(new MsgSetMeta.Builder<DP, PrivateType>().with(desc).with(mTags).build(), null);
|
||||
}
|
||||
return super.subscribe();
|
||||
}
|
||||
|
||||
public void setComment(String comment) {
|
||||
PrivateType p = super.getPriv();
|
||||
if (p == null) {
|
||||
p = new PrivateType();
|
||||
}
|
||||
p.setComment(comment);
|
||||
super.setPriv(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read comment from the Private field.
|
||||
* @return comment or null if comment or Private is not set.
|
||||
*/
|
||||
public String getComment() {
|
||||
PrivateType p = super.getPriv();
|
||||
return p != null ? p.getComment() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set message as pinned or unpinned by adding it to aux.pins array.
|
||||
*
|
||||
* @param seq - seq ID of the message to pin or un-pin.
|
||||
* @param pin - true to pin the message, false to un-pin.
|
||||
*
|
||||
* @return Promise to be resolved/rejected when the server responds to request.
|
||||
*/
|
||||
public PromisedReply<ServerMessage> pinMessage(int seq, boolean pin) {
|
||||
Object val = getAux("pins");
|
||||
List<Integer> pinned;
|
||||
if (val instanceof List) {
|
||||
// Creating a copy, otherwise changes here will affect values saved in topic.
|
||||
pinned = ((List<?>) val).stream()
|
||||
.map(obj -> obj instanceof Number ? ((Number) obj).intValue() : null)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
} else {
|
||||
pinned = new ArrayList<>();
|
||||
}
|
||||
|
||||
boolean changed = false;
|
||||
if (pin) {
|
||||
if (!pinned.contains(seq)) {
|
||||
changed = true;
|
||||
if (pinned.size() == Tinode.MAX_PINNED_COUNT) {
|
||||
pinned.remove(0);
|
||||
}
|
||||
pinned.add(seq);
|
||||
}
|
||||
} else {
|
||||
changed = pinned.removeIf(pseq -> pseq == seq);
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
Map<String, Object> aux = new HashMap<>();
|
||||
aux.put("pins", !pinned.isEmpty() ? pinned : Tinode.NULL_VALUE);
|
||||
return setMeta(new MsgSetMeta.Builder<DP, PrivateType>().with(aux).build());
|
||||
}
|
||||
|
||||
return new PromisedReply<>((ServerMessage) null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the message with a given seqID is pinned.
|
||||
* @param seq seqID of the message to check.
|
||||
* @return true if the message is pinned, false otherwise.
|
||||
*/
|
||||
public boolean isPinned(int seq) {
|
||||
Object val = getAux("pins");
|
||||
return val instanceof List && ((List) val).contains(seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of pinned seqIDs.
|
||||
* @return array of pinned seqIDs or null if there are no pinned messages.
|
||||
*/
|
||||
public int[] getPinned() {
|
||||
Object val = getAux("pins");
|
||||
if (val instanceof List) {
|
||||
int[] pinned = ((List<?>) val).stream()
|
||||
.mapToInt((ToIntFunction<Object>) value ->
|
||||
value instanceof Number ? ((Number) value).intValue() : 0)
|
||||
.filter(value -> value > 0)
|
||||
.toArray();
|
||||
return pinned.length > 0 ? pinned : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash code of the pinned seqIDs.
|
||||
* Changes every time the pinned seqIDs change.
|
||||
* @return hash code of the pinned seqIDs.
|
||||
*/
|
||||
public int getPinnedHash() {
|
||||
Object val = getAux("pins");
|
||||
if (val instanceof List) {
|
||||
return val.hashCode();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pinned messages. The count could be wrong of the list of
|
||||
* pinned messages contains elements other than Number.
|
||||
* @return count of pinned messages.
|
||||
*/
|
||||
public int pinnedCount() {
|
||||
Object val = getAux("pins");
|
||||
if (val instanceof List) {
|
||||
return ((List) val).size();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the topic is archived. Not all topics support archiving.
|
||||
* @return true if the topic is archived, false otherwise.
|
||||
*/
|
||||
@Override
|
||||
public boolean isArchived() {
|
||||
PrivateType p = super.getPriv();
|
||||
Boolean arch = (p != null ? p.isArchived() : Boolean.FALSE);
|
||||
return arch != null ? arch : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the topic is a channel.
|
||||
* @return true if the topic is a channel, false otherwise.
|
||||
*/
|
||||
public boolean isChannel() {
|
||||
return mName != null && isChannel(mName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the topic can be accessed as channel.
|
||||
* @return true if the topic is accessible as a channel.
|
||||
*/
|
||||
public boolean hasChannelAccess() {
|
||||
return mDesc.chan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets flag that the topic is accessible as a channel.
|
||||
* @param access <code>true</code> to indicate that the topic is accessible as a channel.
|
||||
*/
|
||||
public void setHasChannelAccess(boolean access) {
|
||||
mDesc.chan = access;
|
||||
}
|
||||
|
||||
/**
|
||||
* In P2P topics get peer's subscription.
|
||||
*
|
||||
* @return peer's subscription.
|
||||
*/
|
||||
public Subscription<DP, PrivateType> getPeer() {
|
||||
if (isP2PType()) {
|
||||
return super.getSubscription(getName());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle special cases.
|
||||
@SuppressWarnings("unchecked")
|
||||
public Subscription<DP, PrivateType> getSubscription(String key) {
|
||||
if (isSlfType()) {
|
||||
Subscription<DP, PrivateType> sub = new Subscription<>();
|
||||
sub.pub = getPub();
|
||||
return sub;
|
||||
}
|
||||
Subscription<DP, PrivateType> sub = super.getSubscription(key);
|
||||
if (sub == null) {
|
||||
return null;
|
||||
}
|
||||
if (isP2PType() && sub.pub == null) {
|
||||
sub.pub = getName().equals(key) ?
|
||||
getPub() : (DP) mTinode.getMeTopic().getPub();
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive topic by issuing {@link Topic#setMeta} with priv set to {arch: true/false}.
|
||||
*
|
||||
* @throws NotSubscribedException if the client is not subscribed to the topic
|
||||
* @throws NotConnectedException if there is no connection to the server
|
||||
*/
|
||||
public PromisedReply<ServerMessage> updateArchived(final boolean arch) {
|
||||
PrivateType priv = new PrivateType();
|
||||
priv.setArchived(arch);
|
||||
return setMeta(new MsgSetMeta.Builder<DP, PrivateType>().with(new MetaSetDesc<>(null, priv)).build());
|
||||
}
|
||||
|
||||
public static class ComListener<DP> implements Listener<DP,PrivateType,DP,PrivateType> {
|
||||
/** {meta} message received */
|
||||
public void onMeta(MsgServerMeta<DP,PrivateType,DP,PrivateType> meta) {}
|
||||
/** Called by MeTopic when topic descriptor as contact is updated */
|
||||
public void onContUpdate(Subscription<DP,PrivateType> sub) {}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void routeData(MsgServerData data) {
|
||||
if (data.head != null && data.content != null) {
|
||||
// Rewrite VC body with info from the headers.
|
||||
try {
|
||||
String state = (String) data.head.get("webrtc");
|
||||
String mime = (String) data.head.get("mime");
|
||||
if (state != null && Drafty.MIME_TYPE.equals(mime)) {
|
||||
boolean outgoing = ((!isChannel() && data.from == null) || mTinode.isMe(data.from));
|
||||
Drafty.updateVideoEnt(data.content, data.head, !outgoing);
|
||||
}
|
||||
} catch (ClassCastException ignored) {}
|
||||
}
|
||||
super.routeData(data);
|
||||
}
|
||||
|
||||
// Just for convenience.
|
||||
public static class CTListener<DP extends TheCard> implements Listener<DP,PrivateType,DP,PrivateType> {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import org.java_websocket.client.WebSocketClient;
|
||||
import org.java_websocket.drafts.Draft_6455;
|
||||
import org.java_websocket.extensions.permessage_deflate.PerMessageDeflateExtension;
|
||||
import org.java_websocket.handshake.ServerHandshake;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
|
||||
/**
|
||||
* A thinly wrapped websocket connection.
|
||||
*/
|
||||
public class Connection extends WebSocketClient {
|
||||
private static final String TAG = "Connection";
|
||||
|
||||
private static final int CONNECTION_TIMEOUT = 3000; // in milliseconds
|
||||
|
||||
// Connection states
|
||||
// TODO: consider extending ReadyState
|
||||
private enum State {
|
||||
// Created. No attempts were made to reconnect.
|
||||
NEW,
|
||||
// Created, in process of creating or restoring connection.
|
||||
CONNECTING,
|
||||
// Connected.
|
||||
CONNECTED,
|
||||
// Disconnected. A thread is waiting to reconnect again.
|
||||
WAITING_TO_RECONNECT,
|
||||
// Disconnected. Not waiting to reconnect.
|
||||
CLOSED
|
||||
}
|
||||
|
||||
private final WsListener mListener;
|
||||
|
||||
// Connection status
|
||||
private State mStatus;
|
||||
|
||||
// If connection should try to reconnect automatically.
|
||||
private boolean mAutoreconnect;
|
||||
|
||||
// This connection is a background connection.
|
||||
// The value is reset when the connection is successful.
|
||||
private boolean mBackground;
|
||||
|
||||
// Exponential backoff/reconnecting
|
||||
final private ExpBackoff backoff = new ExpBackoff();
|
||||
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
protected Connection(URI endpoint, String apikey, WsListener listener) {
|
||||
super(normalizeEndpoint(endpoint), new Draft_6455(new PerMessageDeflateExtension()),
|
||||
wrapApiKey(apikey), CONNECTION_TIMEOUT);
|
||||
setReuseAddr(true);
|
||||
|
||||
mListener = listener;
|
||||
mStatus = State.NEW;
|
||||
mAutoreconnect = false;
|
||||
mBackground = false;
|
||||
}
|
||||
|
||||
private static Map<String,String> wrapApiKey(String apikey) {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("X-Tinode-APIKey",apikey);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static URI normalizeEndpoint(URI endpoint) {
|
||||
String path = endpoint.getPath();
|
||||
if (path.isEmpty()) {
|
||||
path = "/";
|
||||
} else if (path.lastIndexOf("/") != path.length() - 1) {
|
||||
path += "/";
|
||||
}
|
||||
path += "channels"; // ws://www.example.com:12345/v0/channels
|
||||
|
||||
String scheme = endpoint.getScheme();
|
||||
// Normalize scheme to ws or wss.
|
||||
scheme = ("wss".equals(scheme) || "https".equals(scheme)) ? "wss" : "ws";
|
||||
|
||||
int port = endpoint.getPort();
|
||||
if (port < 0) {
|
||||
port = "wss".equals(scheme) ? 443 : 80;
|
||||
}
|
||||
try {
|
||||
endpoint = new URI(scheme,
|
||||
endpoint.getUserInfo(),
|
||||
endpoint.getHost(),
|
||||
port,
|
||||
path,
|
||||
endpoint.getQuery(),
|
||||
endpoint.getFragment());
|
||||
} catch (URISyntaxException e) {
|
||||
Log.w(TAG, "Invalid endpoint URI", e);
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
private void connectSocket(final boolean reconnect) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
if (reconnect) {
|
||||
reconnectBlocking();
|
||||
} else {
|
||||
connectBlocking(CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
if ("wss".equals(uri.getScheme())) {
|
||||
// SNI: Verify server host name.
|
||||
SSLSession sess = ((SSLSocket) getSocket()).getSession();
|
||||
String hostName = uri.getHost();
|
||||
if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(hostName, sess)) {
|
||||
close();
|
||||
throw new SSLHandshakeException("SNI verification failed. Expected: '" + uri.getHost() +
|
||||
"', actual: '" + sess.getPeerPrincipal() + "'");
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Log.w(TAG, "WS connection failed", ex);
|
||||
if (mListener != null) {
|
||||
mListener.onError(Connection.this, ex);
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish a connection with the server. It opens or reopens a websocket in a separate
|
||||
* thread.
|
||||
* <p>
|
||||
* This is a non-blocking call.
|
||||
*
|
||||
* @param autoReconnect if connection is dropped, reconnect automatically
|
||||
*/
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
synchronized public void connect(boolean autoReconnect, boolean background) {
|
||||
mAutoreconnect = autoReconnect;
|
||||
mBackground = background;
|
||||
|
||||
switch (mStatus) {
|
||||
case CONNECTED:
|
||||
case CONNECTING:
|
||||
// Already connected or in process of connecting: do nothing.
|
||||
break;
|
||||
case WAITING_TO_RECONNECT:
|
||||
backoff.wakeUp();
|
||||
break;
|
||||
case NEW:
|
||||
mStatus = State.CONNECTING;
|
||||
connectSocket(false);
|
||||
break;
|
||||
case CLOSED:
|
||||
mStatus = State.CONNECTING;
|
||||
connectSocket(true);
|
||||
break;
|
||||
// exhaustive, no default:
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully close websocket connection. The socket will attempt
|
||||
* to send a frame to the server.
|
||||
* <p>
|
||||
* The call is idempotent: if connection is already closed it does nothing.
|
||||
* This method is non-blocking.
|
||||
*/
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
public void disconnect() {
|
||||
boolean wakeUp;
|
||||
synchronized (this) {
|
||||
wakeUp = mAutoreconnect;
|
||||
mAutoreconnect = false;
|
||||
}
|
||||
|
||||
// Close the socket on a background thread to avoid blocking the main thread.
|
||||
// WebSocketClient.close() may block waiting to acquire a lock
|
||||
// (even though it's intended to be non-blocking).
|
||||
new Thread(this::close).start();
|
||||
|
||||
if (wakeUp) {
|
||||
// Make sure we are not waiting to reconnect
|
||||
backoff.wakeUp();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the socket is OPEN.
|
||||
*
|
||||
* @return true if the socket is OPEN, false otherwise;
|
||||
*/
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
public boolean isConnected() {
|
||||
return isOpen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the socket is waiting to reconnect.
|
||||
*
|
||||
* @return true if the socket is OPEN, false otherwise;
|
||||
*/
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
public boolean isWaitingToReconnect() {
|
||||
return mStatus == State.WAITING_TO_RECONNECT;
|
||||
}
|
||||
/**
|
||||
* Reset exponential backoff counter to zero.
|
||||
* If autoreconnect is true and WsListener is provided, then WsListener.onConnect must call
|
||||
* this method.
|
||||
*/
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
public void backoffReset() {
|
||||
backoff.reset();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(ServerHandshake handshakeData) {
|
||||
synchronized (this) {
|
||||
mStatus = State.CONNECTED;
|
||||
}
|
||||
|
||||
if (mListener != null) {
|
||||
boolean bkg = mBackground;
|
||||
mBackground = false;
|
||||
mListener.onConnect(this, bkg);
|
||||
} else {
|
||||
backoff.reset();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(String message) {
|
||||
if (mListener != null) {
|
||||
mListener.onMessage(this, message);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(ByteBuffer blob) {
|
||||
// do nothing, server does not send binary frames
|
||||
Log.w(TAG, "binary message received (should not happen)");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose(int code, String reason, boolean remote) {
|
||||
// Avoid infinite recursion
|
||||
synchronized (this) {
|
||||
if (mStatus == State.WAITING_TO_RECONNECT) {
|
||||
return;
|
||||
} else if (mAutoreconnect) {
|
||||
mStatus = State.WAITING_TO_RECONNECT;
|
||||
} else {
|
||||
mStatus = State.CLOSED;
|
||||
}
|
||||
}
|
||||
|
||||
if (mListener != null) {
|
||||
mListener.onDisconnect(this, remote, code, reason);
|
||||
}
|
||||
|
||||
if (mAutoreconnect) {
|
||||
new Thread(() -> {
|
||||
while (mStatus == State.WAITING_TO_RECONNECT) {
|
||||
backoff.doSleep();
|
||||
|
||||
synchronized (Connection.this) {
|
||||
// Check if we no longer need to connect.
|
||||
if (mStatus != State.WAITING_TO_RECONNECT) {
|
||||
break;
|
||||
}
|
||||
mStatus = State.CONNECTING;
|
||||
}
|
||||
connectSocket(true);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Exception ex) {
|
||||
Log.w(TAG, "Websocket error", ex);
|
||||
|
||||
if (mListener != null) {
|
||||
mListener.onError(this, ex);
|
||||
}
|
||||
}
|
||||
|
||||
interface WsListener {
|
||||
default void onConnect(Connection conn, boolean background) {
|
||||
}
|
||||
|
||||
default void onMessage(Connection conn, String message) {
|
||||
}
|
||||
|
||||
default void onDisconnect(Connection conn, boolean byServer, int code, String reason) {
|
||||
}
|
||||
|
||||
default void onError(Connection conn, Exception err) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* Exponential backoff for reconnects.
|
||||
*/
|
||||
public class ExpBackoff {
|
||||
// Minimum delay = 1000ms, expected ~1500ms;
|
||||
private static final int BASE_SLEEP_MS = 1000;
|
||||
// Maximum delay 2^10 ~ 2000 seconds ~ 34 min.
|
||||
private static final int MAX_SHIFT = 10;
|
||||
|
||||
private final Random random = new Random();
|
||||
private int attempt;
|
||||
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
public ExpBackoff() {
|
||||
this.attempt = 0;
|
||||
}
|
||||
|
||||
private Thread currentThread = null;
|
||||
|
||||
/**
|
||||
* Increment attempt counter and return time to sleep in milliseconds
|
||||
* @return time to sleep in milliseconds
|
||||
*/
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
public long getNextDelay() {
|
||||
if (attempt > MAX_SHIFT) {
|
||||
attempt = MAX_SHIFT;
|
||||
}
|
||||
|
||||
long delay = (long) BASE_SLEEP_MS * (1L << attempt) + random.nextInt(BASE_SLEEP_MS * (1 << attempt));
|
||||
attempt++;
|
||||
|
||||
return delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the current thread for the appropriate number of milliseconds.
|
||||
* This method cannot be synchronized!
|
||||
*
|
||||
* @return false if the sleep was interrupted, true otherwise
|
||||
*/
|
||||
@SuppressWarnings({"WeakerAccess", "UnusedReturnValue"})
|
||||
public boolean doSleep() {
|
||||
boolean result;
|
||||
try {
|
||||
currentThread = Thread.currentThread();
|
||||
Thread.sleep(getNextDelay());
|
||||
result = true;
|
||||
} catch (InterruptedException e) {
|
||||
result = false;
|
||||
} finally {
|
||||
currentThread = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
this.attempt = 0;
|
||||
}
|
||||
|
||||
public int getAttemptCount() {
|
||||
return attempt;
|
||||
}
|
||||
|
||||
@SuppressWarnings({"WeakerAccess", "UnusedReturnValue"})
|
||||
synchronized public boolean wakeUp() {
|
||||
reset();
|
||||
if (currentThread != null) {
|
||||
currentThread.interrupt();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import co.tinode.tinodesdk.model.Drafty;
|
||||
import co.tinode.tinodesdk.model.MsgServerMeta;
|
||||
import co.tinode.tinodesdk.model.MsgSetMeta;
|
||||
import co.tinode.tinodesdk.model.ServerMessage;
|
||||
import co.tinode.tinodesdk.model.Subscription;
|
||||
|
||||
// Topic's Public and Private are String. Subscription Public is VCard, Private is String[].
|
||||
public class FndTopic<SP> extends Topic<String, String, SP, String[]> {
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = "FndTopic";
|
||||
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
public FndTopic(Tinode tinode, Listener<String,String,SP,String[]> l) {
|
||||
super(tinode, Tinode.TOPIC_FND, l);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public void setTypes(JavaType typeOfSubPu) {
|
||||
mTinode.setFndTypeOfMetaPacket(typeOfSubPu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PromisedReply<ServerMessage> setMeta(final MsgSetMeta<String, String> meta) {
|
||||
if (mSubs != null) {
|
||||
mSubs = null;
|
||||
mSubsUpdated = null;
|
||||
|
||||
mNotifier.notifySubsUpdated();
|
||||
}
|
||||
return super.setMeta(meta);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected PromisedReply<ServerMessage> publish(Drafty content, Map<String, Object> head, long id) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add subscription to cache. Needs to be overridden in FndTopic because it keeps subs indexed
|
||||
* by either user or topic value.
|
||||
*
|
||||
* @param sub subscription to add to cache
|
||||
*/
|
||||
@Override
|
||||
protected void addSubToCache(Subscription<SP,String[]> sub) {
|
||||
if (mSubs == null) {
|
||||
mSubs = new HashMap<>();
|
||||
}
|
||||
mSubs.put(sub.getUnique(), sub);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void routeMetaSub(MsgServerMeta<String, String,SP,String[]> meta) {
|
||||
for (Subscription<SP,String[]> upd : meta.sub) {
|
||||
Subscription<SP,String[]> sub = getSubscription(upd.getUnique());
|
||||
if (sub != null) {
|
||||
sub.merge(upd);
|
||||
} else {
|
||||
sub = upd;
|
||||
addSubToCache(sub);
|
||||
}
|
||||
|
||||
mNotifier.notifyMetaSub(sub);
|
||||
}
|
||||
|
||||
mNotifier.notifySubsUpdated();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Subscription<SP,String[]> getSubscription(String key) {
|
||||
return mSubs != null ? mSubs.get(key) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Subscription<SP,String[]>> getSubscriptions() {
|
||||
return mSubs != null ? mSubs.values() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setStorage(Storage store) {
|
||||
/* Do nothing: all fnd data is transient. */
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given tag is unique by asking the server.
|
||||
* @param tag tag to check.
|
||||
* @return promise to be resolved with true if the tag is unique, false otherwise.
|
||||
*/
|
||||
public PromisedReply<Boolean> checkTagUniqueness(final String tag, final String caller) {
|
||||
PromisedReply<Boolean> result = new PromisedReply<>();
|
||||
subscribe(null, null)
|
||||
.thenApply(new PromisedReply.SuccessListener<>() {
|
||||
@Override
|
||||
public PromisedReply<ServerMessage> onSuccess(ServerMessage unused) {
|
||||
return setDescription(tag, null, null);
|
||||
}
|
||||
})
|
||||
.thenApply(new PromisedReply.SuccessListener<>() {
|
||||
@Override
|
||||
public PromisedReply<ServerMessage> onSuccess(ServerMessage unused) {
|
||||
return getMeta(getMetaGetBuilder().withTags().build());
|
||||
}
|
||||
})
|
||||
.thenApply(new PromisedReply.SuccessListener<>() {
|
||||
@Override
|
||||
public PromisedReply<ServerMessage> onSuccess(ServerMessage response) throws Exception {
|
||||
if (response.meta == null || response.meta.tags == null) {
|
||||
result.resolve(true);
|
||||
return null;
|
||||
}
|
||||
String[] tags = response.meta.tags;
|
||||
for (String t : tags) {
|
||||
if (t != null && !t.equals(caller)) {
|
||||
result.resolve(false);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
result.resolve(true);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.thenCatch(new PromisedReply.FailureListener<>() {
|
||||
@Override
|
||||
public <E extends Exception> PromisedReply<ServerMessage> onFailure(E err) throws Exception {
|
||||
result.reject(err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Just for convenience.
|
||||
public static class FndListener<SP> implements Listener<String, String, SP, String[]> {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
/**
|
||||
* Exception thrown when certain non-idempotent operations are already in progress, such as login.
|
||||
*/
|
||||
public class InProgressException extends IllegalStateException {
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParseException;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.JsonToken;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
import co.tinode.tinodesdk.model.MsgServerCtrl;
|
||||
import co.tinode.tinodesdk.model.ServerMessage;
|
||||
|
||||
public class LargeFileHelper {
|
||||
private static final int BUFFER_SIZE = 65536;
|
||||
private static final String TWO_HYPHENS = "--";
|
||||
private static final String BOUNDARY = "*****" + System.currentTimeMillis() + "*****";
|
||||
private static final String LINE_END = "\r\n";
|
||||
|
||||
private final URL mUrlUpload;
|
||||
private final String mHost;
|
||||
private final String mApiKey;
|
||||
private final String mAuthToken;
|
||||
private final String mUserAgent;
|
||||
|
||||
private boolean mCanceled = false;
|
||||
|
||||
private int mReqId = 1;
|
||||
|
||||
public LargeFileHelper(URL urlUpload, String apikey, String authToken, String userAgent) {
|
||||
mUrlUpload = urlUpload;
|
||||
mHost = mUrlUpload.getHost();
|
||||
mApiKey = apikey;
|
||||
mAuthToken = authToken;
|
||||
mUserAgent = userAgent;
|
||||
}
|
||||
|
||||
// Upload file out of band. Blocking operation: it should not be called on the UI thread.
|
||||
public ServerMessage upload(@NotNull InputStream in, @NotNull String filename, @NotNull String mimetype, long size,
|
||||
@Nullable String topic, @Nullable FileHelperProgress progress)
|
||||
throws IOException, CancellationException {
|
||||
mCanceled = false;
|
||||
HttpURLConnection conn = null;
|
||||
ServerMessage msg;
|
||||
try {
|
||||
conn = (HttpURLConnection) mUrlUpload.openConnection();
|
||||
conn.setDoOutput(true);
|
||||
conn.setUseCaches(false);
|
||||
conn.setRequestProperty("Connection", "Keep-Alive");
|
||||
conn.setRequestProperty("User-Agent", mUserAgent);
|
||||
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + BOUNDARY);
|
||||
conn.setRequestProperty("X-Tinode-APIKey", mApiKey);
|
||||
if (mAuthToken != null) {
|
||||
// mAuthToken could be null when uploading avatar on sign up.
|
||||
conn.setRequestProperty("X-Tinode-Auth", "Token " + mAuthToken);
|
||||
}
|
||||
conn.setChunkedStreamingMode(0);
|
||||
|
||||
DataOutputStream out = new DataOutputStream(new BufferedOutputStream(conn.getOutputStream()));
|
||||
// Write req ID.
|
||||
out.writeBytes(TWO_HYPHENS + BOUNDARY + LINE_END);
|
||||
out.writeBytes("Content-Disposition: form-data; name=\"id\"" + LINE_END);
|
||||
out.writeBytes(LINE_END);
|
||||
out.writeBytes(++mReqId + LINE_END);
|
||||
|
||||
// Write topic.
|
||||
if (topic != null) {
|
||||
out.writeBytes(TWO_HYPHENS + BOUNDARY + LINE_END);
|
||||
out.writeBytes("Content-Disposition: form-data; name=\"topic\"" + LINE_END);
|
||||
out.writeBytes(LINE_END);
|
||||
out.writeBytes(topic + LINE_END);
|
||||
}
|
||||
|
||||
// File section.
|
||||
out.writeBytes(TWO_HYPHENS + BOUNDARY + LINE_END);
|
||||
// Content-Disposition: form-data; name="file"; filename="1519014549699.pdf"
|
||||
out.writeBytes("Content-Disposition: form-data; name=\"file\"; ");
|
||||
String encFileName = URLEncoder.encode(filename, "UTF-8");
|
||||
if (filename.equals(encFileName)) {
|
||||
// Plain ASCII file name.
|
||||
out.writeBytes("filename=\"" + filename + "\"");
|
||||
} else {
|
||||
// URL-encoded file name.
|
||||
out.writeBytes("filename*=UTF-8''" + encFileName);
|
||||
}
|
||||
out.writeBytes(LINE_END);
|
||||
// Content-Type: application/pdf
|
||||
out.writeBytes("Content-Type: " + mimetype + LINE_END);
|
||||
out.writeBytes("Content-Transfer-Encoding: binary" + LINE_END);
|
||||
out.writeBytes(LINE_END);
|
||||
|
||||
// File bytes.
|
||||
copyStream(in, out, size, progress);
|
||||
out.writeBytes(LINE_END);
|
||||
|
||||
// End of form boundary.
|
||||
out.writeBytes(TWO_HYPHENS + BOUNDARY + TWO_HYPHENS + LINE_END);
|
||||
out.flush();
|
||||
out.close();
|
||||
|
||||
if (conn.getResponseCode() != 200) {
|
||||
throw new IOException("Failed to upload: " + conn.getResponseMessage() +
|
||||
" (" + conn.getResponseCode() + ")");
|
||||
}
|
||||
|
||||
InputStream resp = new BufferedInputStream(conn.getInputStream());
|
||||
msg = readServerResponse(resp);
|
||||
resp.close();
|
||||
} finally {
|
||||
if (conn != null) {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Uploads the file using Runnable, returns PromisedReply. Safe to call on UI thread.
|
||||
public PromisedReply<ServerMessage> uploadAsync(@NotNull InputStream in, @NotNull String filename,
|
||||
@NotNull String mimetype, long size,
|
||||
@Nullable String topic, @Nullable FileHelperProgress progress) {
|
||||
final PromisedReply<ServerMessage> result = new PromisedReply<>();
|
||||
new Thread(() -> {
|
||||
try {
|
||||
ServerMessage msg = upload(in, filename, mimetype, size, topic, progress);
|
||||
if (mCanceled) {
|
||||
throw new CancellationException("Cancelled");
|
||||
}
|
||||
result.resolve(msg);
|
||||
} catch (Exception ex) {
|
||||
try {
|
||||
result.reject(ex);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Download file from the given URL if the URL's host is the default host. Should not be called on the UI thread.
|
||||
public long download(String downloadFrom, OutputStream out, FileHelperProgress progress)
|
||||
throws IOException, CancellationException {
|
||||
URL url = new URL(downloadFrom);
|
||||
long size = 0;
|
||||
String scheme = url.getProtocol();
|
||||
if (!scheme.equals("http") && !scheme.equals("https")) {
|
||||
// As a security measure refuse to download using non-http(s) protocols.
|
||||
return size;
|
||||
}
|
||||
HttpURLConnection urlConnection = null;
|
||||
try {
|
||||
urlConnection = (HttpURLConnection) url.openConnection();
|
||||
if (url.getHost().equals(mHost)) {
|
||||
// Send authentication only if the host is known.
|
||||
urlConnection.setRequestProperty("X-Tinode-APIKey", mApiKey);
|
||||
urlConnection.setRequestProperty("X-Tinode-Auth", "Token " + mAuthToken);
|
||||
}
|
||||
InputStream in = new BufferedInputStream(urlConnection.getInputStream());
|
||||
return copyStream(in, out, urlConnection.getContentLength(), progress);
|
||||
} finally {
|
||||
if (urlConnection != null) {
|
||||
urlConnection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Downloads the file using Runnable, returns PromisedReply. Safe to call on UI thread.
|
||||
public PromisedReply<Long> downloadFuture(final String downloadFrom,
|
||||
final OutputStream out,
|
||||
final FileHelperProgress progress) {
|
||||
final PromisedReply<Long> result = new PromisedReply<>();
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Long size = download(downloadFrom, out, progress);
|
||||
if (mCanceled) {
|
||||
throw new CancellationException("Cancelled");
|
||||
}
|
||||
result.resolve(size);
|
||||
} catch (Exception ex) {
|
||||
try {
|
||||
result.reject(ex);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Try to cancel an ongoing upload or download.
|
||||
public void cancel() {
|
||||
mCanceled = true;
|
||||
}
|
||||
|
||||
public boolean isCanceled() {
|
||||
return mCanceled;
|
||||
}
|
||||
|
||||
private int copyStream(@NotNull InputStream in, @NotNull OutputStream out, long size, @Nullable FileHelperProgress p)
|
||||
throws IOException, CancellationException {
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
int len, sent = 0;
|
||||
while ((len = in.read(buffer)) != -1) {
|
||||
if (mCanceled) {
|
||||
throw new CancellationException("Cancelled");
|
||||
}
|
||||
|
||||
sent += len;
|
||||
out.write(buffer, 0, len);
|
||||
|
||||
if (mCanceled) {
|
||||
throw new CancellationException("Cancelled");
|
||||
}
|
||||
|
||||
if (p != null) {
|
||||
p.onProgress(sent, size);
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
private ServerMessage readServerResponse(InputStream in) throws IOException {
|
||||
MsgServerCtrl ctrl = null;
|
||||
ObjectMapper mapper = Tinode.getJsonMapper();
|
||||
JsonParser parser = mapper.getFactory().createParser(in);
|
||||
if (parser.nextToken() != JsonToken.START_OBJECT) {
|
||||
throw new JsonParseException(parser, "Packet must start with an object",
|
||||
parser.currentLocation());
|
||||
}
|
||||
if (parser.nextToken() != JsonToken.END_OBJECT) {
|
||||
String name = parser.currentName();
|
||||
parser.nextToken();
|
||||
JsonNode node = mapper.readTree(parser);
|
||||
if (name.equals("ctrl")) {
|
||||
ctrl = mapper.readValue(node.traverse(), MsgServerCtrl.class);
|
||||
} else {
|
||||
throw new JsonParseException(parser, "Unexpected message '" + name + "'",
|
||||
parser.currentLocation());
|
||||
}
|
||||
}
|
||||
return new ServerMessage(ctrl);
|
||||
}
|
||||
|
||||
public interface FileHelperProgress {
|
||||
void onProgress(long sent, long size);
|
||||
}
|
||||
|
||||
public Map<String,String> headers() {
|
||||
Map<String,String> headers = new HashMap<>();
|
||||
headers.put("X-Tinode-APIKey", mApiKey);
|
||||
headers.put("X-Tinode-Auth", "Token " + mAuthToken);
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
/**
|
||||
* Empty interface to indicate a set of value which are not synced with the server.
|
||||
* Used for persistent, such as local DB ids.
|
||||
*/
|
||||
public interface LocalData {
|
||||
|
||||
interface Payload {
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
void setLocal(Payload value);
|
||||
|
||||
@JsonIgnore
|
||||
Payload getLocal();
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import co.tinode.tinodesdk.model.Acs;
|
||||
import co.tinode.tinodesdk.model.AcsHelper;
|
||||
import co.tinode.tinodesdk.model.Credential;
|
||||
import co.tinode.tinodesdk.model.Description;
|
||||
import co.tinode.tinodesdk.model.Drafty;
|
||||
import co.tinode.tinodesdk.model.MetaSetSub;
|
||||
import co.tinode.tinodesdk.model.MsgServerCtrl;
|
||||
import co.tinode.tinodesdk.model.MsgServerInfo;
|
||||
import co.tinode.tinodesdk.model.MsgServerMeta;
|
||||
import co.tinode.tinodesdk.model.MsgServerPres;
|
||||
import co.tinode.tinodesdk.model.MsgSetMeta;
|
||||
import co.tinode.tinodesdk.model.PrivateType;
|
||||
import co.tinode.tinodesdk.model.ServerMessage;
|
||||
import co.tinode.tinodesdk.model.Subscription;
|
||||
|
||||
/**
|
||||
* MeTopic manages contact list. MeTopic::Private is unused.
|
||||
*/
|
||||
public class MeTopic<DP> extends Topic<DP,PrivateType,DP,PrivateType> {
|
||||
private static final String TAG = "MeTopic";
|
||||
|
||||
protected MeNotifier<DP> mMeNotifier = new MeNotifier<>(mListeners);
|
||||
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
protected ArrayList<Credential> mCreds;
|
||||
|
||||
public MeTopic(Tinode tinode, Listener<DP,PrivateType,DP,PrivateType> l) {
|
||||
super(tinode, Tinode.TOPIC_ME, l);
|
||||
}
|
||||
|
||||
protected MeTopic(Tinode tinode, Description<DP,PrivateType> desc) {
|
||||
super(tinode, Tinode.TOPIC_ME, desc);
|
||||
}
|
||||
|
||||
public void setTypes(JavaType typeOfPu) {
|
||||
mTinode.setMeTypeOfMetaPacket(typeOfPu);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addSubToCache(Subscription<DP,PrivateType> sub) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void removeSubFromCache(Subscription sub) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PromisedReply<ServerMessage> publish(Drafty content) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PromisedReply<ServerMessage> publish(String content) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Subscription getSubscription(String key) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Subscription<DP,PrivateType>> getSubscriptions() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Date getSubsUpdated() {
|
||||
return mTinode.getTopicsUpdated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's credentials, such as emails and phone numbers.
|
||||
*/
|
||||
public Credential[] getCreds() {
|
||||
return mCreds != null ? mCreds.toArray(new Credential[]{}) : null;
|
||||
}
|
||||
|
||||
public void setCreds(Credential[] creds) {
|
||||
if (creds == null) {
|
||||
mCreds = null;
|
||||
} else {
|
||||
mCreds = new ArrayList<>();
|
||||
for (Credential cred : creds) {
|
||||
if (cred.meth != null && cred.val != null) {
|
||||
mCreds.add(cred);
|
||||
}
|
||||
}
|
||||
Collections.sort(mCreds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete credential.
|
||||
*
|
||||
* @param meth credential method (i.e. "tel" or "email").
|
||||
* @param val value of the credential being deleted, i.e. "alice@example.com".
|
||||
*/
|
||||
public PromisedReply<ServerMessage> delCredential(String meth, String val) {
|
||||
if (mAttached <= 0) {
|
||||
if (mTinode.isConnected()) {
|
||||
return new PromisedReply<>(new NotSubscribedException());
|
||||
}
|
||||
return new PromisedReply<>(new NotConnectedException());
|
||||
}
|
||||
|
||||
final Credential cred = new Credential(meth, val);
|
||||
return mTinode.delCredential(cred).thenApply(new PromisedReply.SuccessListener<>() {
|
||||
@Override
|
||||
public PromisedReply<ServerMessage> onSuccess(ServerMessage result) {
|
||||
if (mCreds == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int idx = findCredIndex(cred, false);
|
||||
if (idx >= 0) {
|
||||
mCreds.remove(idx);
|
||||
|
||||
if (mStore != null) {
|
||||
mStore.topicUpdate(MeTopic.this);
|
||||
}
|
||||
|
||||
// Notify listeners
|
||||
mMeNotifier.notifyCredUpdated(mCreds.toArray(new Credential[]{}));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public PromisedReply<ServerMessage> confirmCred(final String meth, final String resp) {
|
||||
return setMeta(new MsgSetMeta.Builder<DP,PrivateType>()
|
||||
.with(new Credential(meth, null, resp, null)).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public PromisedReply<ServerMessage> updateMode(final String update) {
|
||||
if (mDesc.acs == null) {
|
||||
mDesc.acs = new Acs();
|
||||
}
|
||||
|
||||
final AcsHelper mode = mDesc.acs.getWantHelper();
|
||||
if (mode.update(update)) {
|
||||
return setSubscription(new MetaSetSub(null, mode.toString()));
|
||||
}
|
||||
// The state is unchanged, return resolved promise.
|
||||
return new PromisedReply<>((ServerMessage) null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Topic sent an update to subscription, got a confirmation.
|
||||
*
|
||||
* @param params {ctrl} parameters returned by the server (could be null).
|
||||
* @param sSub updated topic parameters.
|
||||
*/
|
||||
@Override
|
||||
protected void update(Map<String, Object> params, MetaSetSub sSub) {
|
||||
//noinspection unchecked
|
||||
Map<String, String> acsMap = params != null ? (Map<String, String>) params.get("acs") : null;
|
||||
Acs acs;
|
||||
if (acsMap != null) {
|
||||
acs = new Acs(acsMap);
|
||||
} else {
|
||||
acs = new Acs();
|
||||
acs.setWant(sSub.mode);
|
||||
}
|
||||
|
||||
boolean changed;
|
||||
if (mDesc.acs == null) {
|
||||
mDesc.acs = acs;
|
||||
changed = true;
|
||||
} else {
|
||||
changed = mDesc.acs.merge(acs);
|
||||
}
|
||||
|
||||
if (changed && mStore != null) {
|
||||
mStore.topicUpdate(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Topic sent an update to description or subscription, got a confirmation, now
|
||||
* update local data with the new info.
|
||||
*
|
||||
* @param ctrl {ctrl} packet sent by the server
|
||||
* @param meta original {meta} packet updated topic parameters
|
||||
*/
|
||||
@Override
|
||||
protected void update(MsgServerCtrl ctrl, MsgSetMeta<DP,PrivateType> meta) {
|
||||
if (meta.desc != null) {
|
||||
updatePinnedTopics(meta.desc.priv);
|
||||
}
|
||||
|
||||
super.update(ctrl, meta);
|
||||
|
||||
if (meta.cred != null) {
|
||||
routeMetaCred(meta.cred);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void routeMeta(MsgServerMeta<DP,PrivateType,DP,PrivateType> meta) {
|
||||
if (meta.cred != null) {
|
||||
routeMetaCred(meta.cred);
|
||||
}
|
||||
|
||||
if (meta.desc != null) {
|
||||
// Create or update 'me' user in storage.
|
||||
User userMe = mTinode.getUser(mTinode.getMyId());
|
||||
boolean changed;
|
||||
if (userMe == null) {
|
||||
userMe = mTinode.addUser(mTinode.getMyId(), meta.desc);
|
||||
changed = true;
|
||||
} else {
|
||||
//noinspection unchecked
|
||||
changed = userMe.merge(meta.desc);
|
||||
}
|
||||
if (changed && mStore != null) {
|
||||
mStore.userUpdate(userMe);
|
||||
}
|
||||
updatePinnedTopics(meta.desc.priv);
|
||||
}
|
||||
|
||||
super.routeMeta(meta);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPinnedRank() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPinnedRank(int pinned) {
|
||||
/* do nothing */
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin topic to the top of the contact list.
|
||||
*
|
||||
* @param topicName - Name of the topic to pin.
|
||||
* @param pin - If true, pin the topic, otherwise unpin.
|
||||
*
|
||||
* @return Promise to be resolved/rejected when the server responds to request.
|
||||
*/
|
||||
@Override
|
||||
public PromisedReply<ServerMessage> pinTopic(final @NotNull String topicName, boolean pin) {
|
||||
if (mAttached <= 0) {
|
||||
if (mTinode.isConnected()) {
|
||||
return new PromisedReply<>(new NotSubscribedException());
|
||||
}
|
||||
return new PromisedReply<>(new NotConnectedException());
|
||||
}
|
||||
|
||||
if (!isUserType(topicName)) {
|
||||
return new PromisedReply<>(new IllegalArgumentException("Invalid topic type to pin"));
|
||||
}
|
||||
|
||||
List<String> pinned = getPriv() != null ? getPriv().getPinnedTopics() : null;
|
||||
ArrayList<String> tpin = pinned != null ?
|
||||
// Creating a copy to leave original list unchanged.
|
||||
new ArrayList<>(pinned) :
|
||||
// New empty list.
|
||||
new ArrayList<>();
|
||||
|
||||
boolean found = tpin.contains(topicName);
|
||||
if ((pin && found) || (!pin && !found)) {
|
||||
// Nothing to do, return resolved promise.
|
||||
return new PromisedReply<>(null);
|
||||
}
|
||||
|
||||
if (pin) {
|
||||
// Add topic to the top of the pinned list.
|
||||
tpin.add(0, topicName);
|
||||
} else {
|
||||
// Remove topic from the pinned list.
|
||||
tpin.remove(topicName);
|
||||
}
|
||||
final PrivateType priv = new PrivateType();
|
||||
priv.setPinnedTopics(tpin);
|
||||
return setDescription(null, priv, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rank of the pinned topic.
|
||||
* @param topicName - Name of the topic to check.
|
||||
*
|
||||
* @return numeric rank of the pinned topic in the range 1..N (N being the top,
|
||||
* N - the number of pinned topics) or 0 if not pinned.
|
||||
*/
|
||||
@Override
|
||||
public int pinnedTopicRank(final @NotNull String topicName) {
|
||||
PrivateType priv = getPriv();
|
||||
if (priv == null) {
|
||||
return 0;
|
||||
}
|
||||
return priv.getPinnedRank(topicName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check of the given topic is pinned (pinnedRank > 0).
|
||||
* @param topicName - Name of the topic to check.
|
||||
*
|
||||
* @return true if pinned, false otherwise.
|
||||
*/
|
||||
public boolean isPinned(final @NotNull String topicName) {
|
||||
PrivateType priv = getPriv();
|
||||
if (priv == null) {
|
||||
return false;
|
||||
}
|
||||
return priv.getPinnedRank(topicName) > 0;
|
||||
}
|
||||
|
||||
private void updatePinnedTopics(final PrivateType priv) {
|
||||
List<String> newPins = priv != null ? priv.getPinnedTopics() : null;
|
||||
if (newPins == null) {
|
||||
return;
|
||||
}
|
||||
// Update pinned rank for all pinned topics.
|
||||
int rank = newPins.size();
|
||||
for (String topicName : newPins) {
|
||||
Topic topic = mTinode.getTopic(topicName);
|
||||
if (topic != null) {
|
||||
topic.setPinnedRank(rank);
|
||||
if (mStore != null) {
|
||||
mStore.topicUpdate(topic);
|
||||
}
|
||||
}
|
||||
rank --;
|
||||
}
|
||||
List<String> thesePins = getPriv() != null ? getPriv().getPinnedTopics() : null;
|
||||
if (thesePins == null || thesePins.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// Unpin topics that were removed from the pinned list.
|
||||
for (String topicName : thesePins) {
|
||||
if (!newPins.contains(topicName)) {
|
||||
Topic topic = mTinode.getTopic(topicName);
|
||||
if (topic != null) {
|
||||
topic.setPinnedRank(0);
|
||||
if (mStore != null) {
|
||||
mStore.topicUpdate(topic);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void routeMetaSub(MsgServerMeta<DP,PrivateType,DP,PrivateType> meta) {
|
||||
for (Subscription<DP,PrivateType> sub : meta.sub) {
|
||||
processOneSub(sub);
|
||||
}
|
||||
mMeNotifier.notifySubsUpdated();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void processOneSub(Subscription<DP,PrivateType> sub) {
|
||||
// Handle topic.
|
||||
Topic topic = mTinode.getTopic(sub.topic);
|
||||
if (topic != null) {
|
||||
// This is an existing topic.
|
||||
if (sub.deleted != null) {
|
||||
// Expunge deleted topic
|
||||
if (topic.isDeleted()) {
|
||||
mTinode.stopTrackingTopic(sub.topic);
|
||||
topic.expunge(true);
|
||||
} else {
|
||||
topic.expunge(false);
|
||||
}
|
||||
topic = null;
|
||||
} else {
|
||||
// Update its record in memory and in the database.
|
||||
if (topic.update(sub)) {
|
||||
// Notify topic to update self.
|
||||
topic.mNotifier.notifyMetaDesc(topic.mDesc);
|
||||
}
|
||||
}
|
||||
} else if (sub.deleted == null) {
|
||||
// This is a new topic. Register it and write to DB.
|
||||
topic = mTinode.newTopic(sub);
|
||||
topic.persist();
|
||||
} else {
|
||||
Log.w(TAG, "Request to delete an unknown topic: " + sub.topic);
|
||||
}
|
||||
|
||||
if (topic != null) {
|
||||
int pinnedRank = pinnedTopicRank(sub.topic);
|
||||
if (topic.getPinnedRank() != pinnedRank) {
|
||||
topic.setPinnedRank(pinnedRank);
|
||||
if (mStore != null) {
|
||||
mStore.topicUpdate(topic);
|
||||
}
|
||||
}
|
||||
// Use p2p topic to update user's record.
|
||||
if (topic.getTopicType() == TopicType.P2P) {
|
||||
// Use P2P description to generate and update user
|
||||
User user = mTinode.getUser(topic.getName());
|
||||
boolean changed;
|
||||
if (user == null) {
|
||||
user = mTinode.addUser(topic.getName(), topic.mDesc);
|
||||
changed = true;
|
||||
} else {
|
||||
changed = user.merge(topic.mDesc);
|
||||
}
|
||||
if (changed && mStore != null) {
|
||||
mStore.userUpdate(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
mMeNotifier.notifyMetaSub(sub);
|
||||
}
|
||||
|
||||
private int findCredIndex(Credential other, boolean anyUnconfirmed) {
|
||||
int i = 0;
|
||||
for (Credential cred : mCreds) {
|
||||
if (cred.meth.equals(other.meth) && ((anyUnconfirmed && !cred.isDone()) || cred.val.equals(other.val))) {
|
||||
return i;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void processOneCred(Credential cred) {
|
||||
if (cred.meth == null) {
|
||||
// Skip invalid method;
|
||||
return;
|
||||
}
|
||||
|
||||
boolean changed = false;
|
||||
if (cred.val != null) {
|
||||
if (mCreds == null) {
|
||||
// Empty list. Create and add.
|
||||
mCreds = new ArrayList<>();
|
||||
mCreds.add(cred);
|
||||
} else {
|
||||
// Try finding this credential among confirmed or not.
|
||||
int idx = findCredIndex(cred, false);
|
||||
if (idx < 0) {
|
||||
// Not found.
|
||||
if (!cred.isDone()) {
|
||||
// Unconfirmed credential replaces previous unconfirmed credential of the same method.
|
||||
idx = findCredIndex(cred, true);
|
||||
if (idx >= 0) {
|
||||
// Remove previous unconfirmed credential.
|
||||
mCreds.remove(idx);
|
||||
}
|
||||
}
|
||||
mCreds.add(cred);
|
||||
} else {
|
||||
// Found. Maybe change 'done' status.
|
||||
Credential el = mCreds.get(idx);
|
||||
el.done = cred.isDone();
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
} else if (cred.resp != null && mCreds != null) {
|
||||
// Handle credential confirmation.
|
||||
int idx = findCredIndex(cred, true);
|
||||
if (idx >= 0) {
|
||||
Credential el = mCreds.get(idx);
|
||||
el.done = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
if (mCreds != null) {
|
||||
Collections.sort(mCreds);
|
||||
}
|
||||
|
||||
if (mStore != null) {
|
||||
mStore.topicUpdate(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
protected void routeMetaCred(Credential cred) {
|
||||
processOneCred(cred);
|
||||
|
||||
mMeNotifier.notifyCredUpdated(mCreds.toArray(new Credential[]{}));
|
||||
}
|
||||
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
protected void routeMetaCred(Credential[] creds) {
|
||||
mCreds = new ArrayList<>();
|
||||
for (Credential cred : creds) {
|
||||
if (cred.meth != null && cred.val != null) {
|
||||
mCreds.add(cred);
|
||||
}
|
||||
}
|
||||
Collections.sort(mCreds);
|
||||
|
||||
if (mStore != null) {
|
||||
mStore.topicUpdate(this);
|
||||
}
|
||||
|
||||
mMeNotifier.notifyCredUpdated(creds);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void routePres(MsgServerPres pres) {
|
||||
MsgServerPres.What what = MsgServerPres.parseWhat(pres.what);
|
||||
if (what == MsgServerPres.What.TERM) {
|
||||
super.routePres(pres);
|
||||
return;
|
||||
}
|
||||
|
||||
if (what == MsgServerPres.What.UPD) {
|
||||
if (Tinode.TOPIC_ME.equals(pres.src)) {
|
||||
// Update to me topic itself.
|
||||
getMeta(getMetaGetBuilder().withDesc().build());
|
||||
} else {
|
||||
// pub/priv updated: fetch subscription update.
|
||||
getMeta(getMetaGetBuilder().withSub(pres.src).build());
|
||||
}
|
||||
} else {
|
||||
Topic topic = mTinode.getTopic(pres.src);
|
||||
if (topic != null) {
|
||||
switch (what) {
|
||||
case ON: // topic came online
|
||||
topic.setOnline(true);
|
||||
break;
|
||||
|
||||
case OFF: // topic went offline
|
||||
topic.setOnline(false);
|
||||
topic.setLastSeen(new Date());
|
||||
break;
|
||||
|
||||
case MSG: // new message received
|
||||
topic.setSeqAndFetch(pres.seq);
|
||||
if (pres.act == null || mTinode.isMe(pres.act)) {
|
||||
// Message is sent by the current user.
|
||||
assignRead(topic, pres.seq);
|
||||
}
|
||||
topic.setTouched(new Date());
|
||||
break;
|
||||
|
||||
case ACS: // access mode changed
|
||||
if (pres.tgt == null && topic.updateAccessMode(pres.dacs) && mStore != null) {
|
||||
// tgt is null means permissions are for the current user.
|
||||
mStore.topicUpdate(topic);
|
||||
}
|
||||
break;
|
||||
|
||||
case UA: // user agent changed
|
||||
topic.setLastSeen(new Date(), pres.ua);
|
||||
break;
|
||||
|
||||
case RECV: // user's other session marked some messages as received
|
||||
assignRecv(topic, pres.seq);
|
||||
break;
|
||||
|
||||
case READ: // user's other session marked some messages as read
|
||||
assignRead(topic, pres.seq);
|
||||
break;
|
||||
|
||||
case DEL: // messages deleted
|
||||
// TODO(gene): add handling for del
|
||||
break;
|
||||
|
||||
case GONE:
|
||||
// If topic is unknown (==null), then we don't care to unregister it.
|
||||
if (topic.isDeleted()) {
|
||||
mTinode.stopTrackingTopic(pres.src);
|
||||
topic.expunge( true);
|
||||
} else {
|
||||
topic.expunge(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
switch (what) {
|
||||
case ACS:
|
||||
Acs acs = new Acs();
|
||||
acs.update(pres.dacs);
|
||||
if (acs.isModeDefined()) {
|
||||
getMeta(getMetaGetBuilder().withSub(pres.src).build());
|
||||
} else {
|
||||
Log.d(TAG, "Unexpected access mode in presence: '" + pres.dacs.want + "'/'" + pres.dacs.given + "'");
|
||||
}
|
||||
break;
|
||||
case TAGS:
|
||||
// Tags in 'me' topic updated.
|
||||
getMeta(getMetaGetBuilder().withTags().build());
|
||||
break;
|
||||
default:
|
||||
Log.d(TAG, "Topic not found in me.routePres: " + pres.what + " in " + pres.src);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (what == MsgServerPres.What.GONE) {
|
||||
mMeNotifier.notifySubsUpdated();
|
||||
}
|
||||
mMeNotifier.notifyPres(pres);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void routeInfo(MsgServerInfo info) {
|
||||
if (info.src == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (info.what) {
|
||||
case Tinode.NOTE_KP:
|
||||
case Tinode.NOTE_REC_AUDIO:
|
||||
case Tinode.NOTE_REC_VIDEO:
|
||||
case Tinode.NOTE_CALL:
|
||||
break;
|
||||
|
||||
case Tinode.NOTE_RECV:
|
||||
case Tinode.NOTE_READ:
|
||||
Topic topic = mTinode.getTopic(info.src);
|
||||
if (topic != null) {
|
||||
topic.setReadRecvByRemote(info.from, info.what, info.seq);
|
||||
}
|
||||
|
||||
// If this is an update from the current user, update the contact with the new count too.
|
||||
if (mTinode.isMe(info.from)) {
|
||||
setMsgReadRecv(info.src, info.what, info.seq);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown notification ignored.
|
||||
}
|
||||
|
||||
mMeNotifier.notifyInfo(info);
|
||||
}
|
||||
|
||||
private void assignRead(Topic topic, int seq) {
|
||||
if (topic.getRead() < seq) {
|
||||
topic.setRead(seq);
|
||||
if (mStore != null) {
|
||||
mStore.setRead(topic, seq);
|
||||
}
|
||||
assignRecv(topic, topic.getRead());
|
||||
}
|
||||
}
|
||||
|
||||
private void assignRecv(Topic topic, int seq) {
|
||||
if (topic.getRecv() < seq) {
|
||||
topic.setRecv(seq);
|
||||
if (mStore != null) {
|
||||
mStore.setRecv(topic, seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void setMsgReadRecv(String topicName, String what, int seq) {
|
||||
if (seq > 0) {
|
||||
final Topic topic = mTinode.getTopic(topicName);
|
||||
if (topic == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (what) {
|
||||
case Tinode.NOTE_RECV:
|
||||
assignRecv(topic, seq);
|
||||
break;
|
||||
case Tinode.NOTE_READ:
|
||||
assignRead(topic, seq);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
mMeNotifier.notifyContUpdated(topicName);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void topicLeft(boolean unsub, int code, String reason) {
|
||||
super.topicLeft(unsub, code, reason);
|
||||
|
||||
Collection<Topic> topics = mTinode.getTopics();
|
||||
if (topics != null) {
|
||||
for (Topic t : topics) {
|
||||
t.setOnline(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class MeListener<DP> implements Listener<DP,PrivateType,DP,PrivateType> {
|
||||
/** Called by MeTopic when credentials are updated */
|
||||
public void onCredUpdated(Credential[] cred) {}
|
||||
}
|
||||
|
||||
public static class MeNotifier<DP>
|
||||
extends Topic.ListenerNotifier<Listener<DP,PrivateType,DP,PrivateType>, DP,PrivateType,DP,PrivateType> {
|
||||
MeNotifier(List<Listener<DP, PrivateType,DP,PrivateType>> initialListeners) {
|
||||
super(initialListeners);
|
||||
}
|
||||
|
||||
public void notifyCredUpdated(Credential[] cred) {
|
||||
for (Listener<DP, PrivateType,DP,PrivateType> l : snapshot()) {
|
||||
if (l instanceof MeListener) {
|
||||
((MeListener<DP>)l).onCredUpdated(cred);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public MetaGetBuilder getMetaGetBuilder() {
|
||||
return new MetaGetBuilder(this);
|
||||
}
|
||||
|
||||
public static class MetaGetBuilder extends Topic.MetaGetBuilder {
|
||||
MetaGetBuilder(MeTopic parent) {
|
||||
super(parent);
|
||||
}
|
||||
|
||||
public MetaGetBuilder withCred() {
|
||||
meta.setCred();
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
/**
|
||||
* Exception generated in response to a packet containing an error code.
|
||||
*/
|
||||
public class NotConnectedException extends IllegalStateException {
|
||||
|
||||
public NotConnectedException() {
|
||||
this((Throwable) null);
|
||||
}
|
||||
|
||||
public NotConnectedException(String s) {
|
||||
this(s, null);
|
||||
}
|
||||
|
||||
public NotConnectedException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public NotConnectedException(Throwable cause) {
|
||||
this("Not connected", cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
/**
|
||||
* Attempt to interact with a topic without subscribing first
|
||||
*/
|
||||
public class NotSubscribedException extends IllegalStateException {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
/**
|
||||
* Attempt to modify a topic which exists only locally.
|
||||
*/
|
||||
public class NotSynchronizedException extends IllegalStateException {
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
/**
|
||||
* A very simple thanable promise. It has no facility for execution. It can only be
|
||||
* resolved/rejected externally by calling resolve/reject. Once resolved/rejected it will call
|
||||
* listener's onSuccess/onFailure. Depending on results returned or thrown by the handler, it will
|
||||
* update the next promise in chain: will either resolve/reject it immediately, or make it
|
||||
* resolve/reject together with the promise returned by the handler.
|
||||
* <p>
|
||||
* Usage:
|
||||
* <p>
|
||||
* Create a PromisedReply P1, assign onSuccess/onFailure listeners by calling thenApply. thenApply returns
|
||||
* another P2 promise (mNextPromise), which can then be assigned its own listeners.
|
||||
* <p>
|
||||
* Alternatively, one can use a blocking call getResult. It will block until the promise is either
|
||||
* resolved or rejected.
|
||||
* <p>
|
||||
* The promise can be created in either WAITING or RESOLVED state by using an appropriate constructor.
|
||||
* <p>
|
||||
* The onSuccess/onFailure handlers will be called:
|
||||
* <p>
|
||||
* a. Called at the time of resolution when P1 is resolved through P1.resolve(T) if at the time of
|
||||
* calling thenApply the promise is in WAITING state,
|
||||
* b. Called immediately on thenApply if at the time of calling thenApply the promise is already
|
||||
* in RESOLVED or REJECTED state,
|
||||
* <p>
|
||||
* thenApply creates and returns a promise P2 which will be resolved/rejected in the following
|
||||
* manner:
|
||||
* <p>
|
||||
* A. If P1 is resolved:
|
||||
* 1. If P1.onSuccess returns a resolved promise P3, P2 is resolved immediately on
|
||||
* return from onSuccess using the result from P3.
|
||||
* 2. If P1.onSuccess returns a rejected promise P3, P2 is rejected immediately on
|
||||
* return from onSuccess using the throwable from P3.
|
||||
* 2. If P1.onSuccess returns null, P2 is resolved immediately using result from P1.
|
||||
* 3. If P1.onSuccess returns an unresolved promise P3, P2 is resolved together with P3.
|
||||
* 4. If P1.onSuccess throws an exception, P2 is rejected immediately on catching the exception.
|
||||
* 5. If P1.onSuccess is null, P2 is resolved immediately using result from P1.
|
||||
* <p>
|
||||
* B. If P1 is rejected:
|
||||
* 1. If P1.onFailure returns a resolved promise P3, P2 is resolved immediately on return from
|
||||
* onFailure using the result from P3.
|
||||
* 2. If P1.onFailure returns null, P2 is resolved immediately using null as a result.
|
||||
* 3. If P1.onFailure returns an unresolved promise P3, P2 is resolved together with P3.
|
||||
* 4. If P1.onFailure throws an exception, P2 is rejected immediately on catching the exception.
|
||||
* 5. If P1.onFailure is null, P2 is rejected immediately using the throwable from P1.
|
||||
* 5.1 If P2.onFailure is null, and P2.mNextPromise is null, an exception is re-thrown.
|
||||
*
|
||||
*/
|
||||
public class PromisedReply<T> {
|
||||
private static final String TAG = "PromisedReply";
|
||||
|
||||
private enum State {WAITING, RESOLVED, REJECTED}
|
||||
|
||||
private T mResult = null;
|
||||
private Exception mException = null;
|
||||
|
||||
private volatile State mState = State.WAITING;
|
||||
|
||||
private SuccessListener<T> mSuccess = null;
|
||||
private FailureListener<T> mFailure = null;
|
||||
|
||||
private PromisedReply<T> mNextPromise = null;
|
||||
|
||||
private final CountDownLatch mDoneSignal;
|
||||
|
||||
/**
|
||||
* Create promise in a WAITING state.
|
||||
*/
|
||||
public PromisedReply() {
|
||||
mDoneSignal = new CountDownLatch(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a promise in a RESOLVED state
|
||||
*
|
||||
* @param result result used for resolution of the promise.
|
||||
*/
|
||||
public PromisedReply(T result) {
|
||||
mResult = result;
|
||||
mState = State.RESOLVED;
|
||||
mDoneSignal = new CountDownLatch(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a promise in a REJECTED state
|
||||
*
|
||||
* @param err Exception used for rejecting the promise.
|
||||
*/
|
||||
public <E extends Exception> PromisedReply(E err) {
|
||||
mException = err;
|
||||
mState = State.REJECTED;
|
||||
mDoneSignal = new CountDownLatch(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new PromisedReply that is completed when all of the given PromisedReply complete.
|
||||
* It is rejected if any one is rejected. If resolved, the result is an array or values returned by each input promise.
|
||||
* If rejected, the result if the exception which rejected one of the input promises.
|
||||
*
|
||||
* @param waitFor promises to wait for.
|
||||
* @return PromisedReply which is resolved when all inputs are resolved or rejected when any one is rejected.
|
||||
*/
|
||||
public static <T> PromisedReply<T[]> allOf(PromisedReply<T>[] waitFor) {
|
||||
final PromisedReply<T[]> done = new PromisedReply<>();
|
||||
// Create a separate thread and wait for all promises to resolve.
|
||||
new Thread(() -> {
|
||||
for (PromisedReply p : waitFor) {
|
||||
if (p != null) {
|
||||
try {
|
||||
p.mDoneSignal.await();
|
||||
if (p.mState == State.REJECTED) {
|
||||
done.reject(p.mException);
|
||||
}
|
||||
} catch (InterruptedException ex) {
|
||||
try {
|
||||
done.reject(ex);
|
||||
} catch (Exception ignored) {}
|
||||
return;
|
||||
} catch (Exception ignored) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ArrayList<T> result = new ArrayList<>();
|
||||
for (PromisedReply<T> p : waitFor) {
|
||||
if (p != null) {
|
||||
result.add(p.mResult);
|
||||
} else {
|
||||
result.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
// If it throws then nothing we can do about it.
|
||||
try {
|
||||
// noinspection unchecked
|
||||
done.resolve((T[]) result.toArray());
|
||||
} catch (Exception ignored) {}
|
||||
}).start();
|
||||
return done;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call SuccessListener.onSuccess or FailureListener.onFailure when the
|
||||
* promise is resolved or rejected. The call will happen on the thread which
|
||||
* called resolve() or reject().
|
||||
*
|
||||
* @param success called when the promise is resolved
|
||||
* @param failure called when the promise is rejected
|
||||
* @return promise for chaining
|
||||
*/
|
||||
public PromisedReply<T> thenApply(SuccessListener<T> success, FailureListener<T> failure) {
|
||||
synchronized (this) {
|
||||
|
||||
if (mNextPromise != null) {
|
||||
throw new IllegalStateException("Multiple calls to thenApply are not supported");
|
||||
}
|
||||
|
||||
mSuccess = success;
|
||||
mFailure = failure;
|
||||
mNextPromise = new PromisedReply<>();
|
||||
try {
|
||||
switch (mState) {
|
||||
case RESOLVED:
|
||||
callOnSuccess(mResult);
|
||||
break;
|
||||
|
||||
case REJECTED:
|
||||
callOnFailure(mException);
|
||||
break;
|
||||
|
||||
case WAITING:
|
||||
break;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
mNextPromise = new PromisedReply<>(e);
|
||||
}
|
||||
|
||||
return mNextPromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls SuccessListener.onSuccess when the promise is resolved. The call will happen on the
|
||||
* thread which called resolve().
|
||||
*
|
||||
* @param success called when the promise is resolved
|
||||
* @return promise for chaining
|
||||
*/
|
||||
public PromisedReply<T> thenApply(SuccessListener<T> success) {
|
||||
return thenApply(success, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call onFailure when the promise is rejected. The call will happen on the
|
||||
* thread which called reject()
|
||||
*
|
||||
* @param failure called when the promise is rejected
|
||||
* @return promise for chaining
|
||||
*/
|
||||
public PromisedReply<T> thenCatch(FailureListener<T> failure) {
|
||||
return thenApply(null, failure);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call FinalListener.onFinally when the promise is completed. The call will happen on the
|
||||
* thread which completed the promise: called either resolve() or reject().
|
||||
*
|
||||
* @param finished called when the promise is completed either way.
|
||||
*/
|
||||
public void thenFinally(final FinalListener finished) {
|
||||
thenApply(new SuccessListener<>() {
|
||||
@Override
|
||||
public PromisedReply<T> onSuccess(T result) {
|
||||
finished.onFinally();
|
||||
return null;
|
||||
}
|
||||
}, new FailureListener<>() {
|
||||
@Override
|
||||
public <E extends Exception> PromisedReply<T> onFailure(E err) {
|
||||
finished.onFinally();
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
public boolean isResolved() {
|
||||
return mState == State.RESOLVED;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public boolean isRejected() {
|
||||
return mState == State.REJECTED;
|
||||
}
|
||||
|
||||
@SuppressWarnings({"WeakerAccess"})
|
||||
public boolean isDone() {
|
||||
return mState == State.RESOLVED || mState == State.REJECTED;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Make this promise resolved.
|
||||
*
|
||||
* @param result results of resolution.
|
||||
* @throws Exception if anything goes wrong during resolution.
|
||||
*/
|
||||
public void resolve(final T result) throws Exception {
|
||||
synchronized (this) {
|
||||
if (mState == State.WAITING) {
|
||||
mState = State.RESOLVED;
|
||||
|
||||
mResult = result;
|
||||
try {
|
||||
callOnSuccess(result);
|
||||
} finally {
|
||||
mDoneSignal.countDown();
|
||||
}
|
||||
} else {
|
||||
mDoneSignal.countDown();
|
||||
throw new IllegalStateException("Promise is already completed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make this promise rejected.
|
||||
*
|
||||
* @param err reason for rejecting this promise.
|
||||
* @throws Exception if anything goes wrong during rejection.
|
||||
*/
|
||||
public void reject(final Exception err) throws Exception {
|
||||
synchronized (this) {
|
||||
if (mState == State.WAITING) {
|
||||
mState = State.REJECTED;
|
||||
|
||||
mException = err;
|
||||
try {
|
||||
callOnFailure(err);
|
||||
} finally {
|
||||
mDoneSignal.countDown();
|
||||
}
|
||||
} else {
|
||||
mDoneSignal.countDown();
|
||||
throw new IllegalStateException("Promise is already completed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for promise resolution.
|
||||
*
|
||||
* @return true if the promise was resolved, false otherwise
|
||||
* @throws InterruptedException if waiting was interrupted
|
||||
*/
|
||||
public boolean waitResult() throws InterruptedException {
|
||||
// Wait for the promise to resolve
|
||||
mDoneSignal.await();
|
||||
return isResolved();
|
||||
}
|
||||
|
||||
/**
|
||||
* A blocking call which returns the result of the execution. It will return
|
||||
* <b>after</b> thenApply is called. It can be safely called multiple times on
|
||||
* the same instance.
|
||||
*
|
||||
* @return result of the execution (what was passed to {@link #resolve(Object)})
|
||||
* @throws Exception if the promise was rejected or waiting was interrupted.
|
||||
*/
|
||||
public T getResult() throws Exception {
|
||||
// Wait for the promise to resolve
|
||||
mDoneSignal.await();
|
||||
|
||||
return switch (mState) {
|
||||
case RESOLVED -> mResult;
|
||||
case REJECTED -> throw mException;
|
||||
default -> throw new IllegalStateException("Promise cannot be in WAITING state");
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
private void callOnSuccess(final T result) throws Exception {
|
||||
PromisedReply<T> ret;
|
||||
try {
|
||||
ret = (mSuccess != null ? mSuccess.onSuccess(result) : null);
|
||||
} catch (Exception e) {
|
||||
handleFailure(e);
|
||||
return;
|
||||
}
|
||||
// If it throws, let it fly.
|
||||
handleSuccess(ret);
|
||||
}
|
||||
|
||||
private void callOnFailure(final Exception err) throws Exception {
|
||||
if (mFailure != null) {
|
||||
// Try to recover
|
||||
try {
|
||||
handleSuccess(mFailure.onFailure(err));
|
||||
} catch (Exception ex) {
|
||||
handleFailure(ex);
|
||||
}
|
||||
} else {
|
||||
// Pass to the next handler
|
||||
handleFailure(err);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSuccess(PromisedReply<T> ret) throws Exception {
|
||||
if (mNextPromise == null) {
|
||||
if (ret != null && ret.mState == State.REJECTED) {
|
||||
throw ret.mException;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ret == null) {
|
||||
mNextPromise.resolve(mResult);
|
||||
} else if (ret.mState == State.RESOLVED) {
|
||||
mNextPromise.resolve(ret.mResult);
|
||||
} else if (ret.mState == State.REJECTED) {
|
||||
mNextPromise.reject(ret.mException);
|
||||
} else {
|
||||
// Next promise will be called when ret is completed
|
||||
ret.insertNextPromise(mNextPromise);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleFailure(Exception e) throws Exception {
|
||||
if (mNextPromise != null) {
|
||||
mNextPromise.reject(e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void insertNextPromise(PromisedReply<T> next) {
|
||||
synchronized (this) {
|
||||
if (mNextPromise != null) {
|
||||
next.insertNextPromise(mNextPromise);
|
||||
}
|
||||
mNextPromise = next;
|
||||
}
|
||||
}
|
||||
|
||||
public static abstract class SuccessListener<U> {
|
||||
/**
|
||||
* Callback to execute when the promise is successfully resolved.
|
||||
*
|
||||
* @param result result of the call.
|
||||
* @return new promise to pass to the next handler in the chain or null to use the same result.
|
||||
* @throws Exception thrown if handler want to call the next failure handler in chain.
|
||||
*/
|
||||
public abstract PromisedReply<U> onSuccess(U result) throws Exception;
|
||||
}
|
||||
|
||||
public static abstract class FailureListener<U> {
|
||||
/**
|
||||
* Callback to execute when the promise is rejected.
|
||||
*
|
||||
* @param err Exception which caused promise to fail.
|
||||
* @return new promise to pass to the next success handler in the chain.
|
||||
* @throws Exception thrown if handler want to call the next failure handler in chain.
|
||||
*/
|
||||
public abstract <E extends Exception> PromisedReply<U> onFailure(E err) throws Exception;
|
||||
}
|
||||
|
||||
public static abstract class FinalListener {
|
||||
public abstract void onFinally();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
|
||||
/**
|
||||
* Augmented SimpleDateFormat for handling optional milliseconds in RFC3339 timestamps.
|
||||
*/
|
||||
public class RFC3339Format extends SimpleDateFormat {
|
||||
private final SimpleDateFormat mShortDate;
|
||||
|
||||
public RFC3339Format() {
|
||||
super("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",Locale.US);
|
||||
mShortDate = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
|
||||
|
||||
setTimeZone(TimeZone.getTimeZone("UTC"));
|
||||
mShortDate.setTimeZone(TimeZone.getTimeZone("UTC"));
|
||||
}
|
||||
|
||||
// Server may generate timestamps without milliseconds.
|
||||
// SDF cannot parse optional millis. Must treat them explicitly.
|
||||
@Override
|
||||
public Date parse(@NotNull String text) throws ParseException {
|
||||
Date date;
|
||||
try {
|
||||
date = super.parse(text);
|
||||
} catch (ParseException ignore) {
|
||||
date = mShortDate.parse(text);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
/**
|
||||
* Exception generated in response to a packet containing an error code.
|
||||
*/
|
||||
public class ServerResponseException extends Exception {
|
||||
private final int code;
|
||||
private final String reason;
|
||||
|
||||
ServerResponseException(int code, String text, String reason) {
|
||||
super(text);
|
||||
this.code = code;
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
ServerResponseException(int code, String text) {
|
||||
super(text);
|
||||
this.code = code;
|
||||
this.reason = text;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMessage() {
|
||||
return super.getMessage() + " (" + code + ")";
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
import co.tinode.tinodesdk.model.Drafty;
|
||||
import co.tinode.tinodesdk.model.MsgRange;
|
||||
import co.tinode.tinodesdk.model.MsgServerData;
|
||||
import co.tinode.tinodesdk.model.Subscription;
|
||||
|
||||
/**
|
||||
* Interface for implementing persistence.
|
||||
*/
|
||||
public interface Storage {
|
||||
String getMyUid();
|
||||
// Update UID and clear unvalidated credentials.
|
||||
void setMyUid(String uid, String hostURI);
|
||||
// Server-requested validation credentials for the currently active account.
|
||||
void updateCredentials(String[] credRequired);
|
||||
|
||||
// Delete given account.
|
||||
void deleteAccount(String uid);
|
||||
|
||||
String getServerURI();
|
||||
|
||||
String getDeviceToken();
|
||||
void saveDeviceToken(String token);
|
||||
|
||||
void logout();
|
||||
|
||||
// Server time minus local time
|
||||
void setTimeAdjustment(long adjustment);
|
||||
|
||||
boolean isReady();
|
||||
|
||||
// Fetch all topics
|
||||
Topic[] topicGetAll(Tinode tinode);
|
||||
// Fetch one topic by name
|
||||
Topic topicGet(Tinode tinode, String name);
|
||||
// Add new topic
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
long topicAdd(Topic topic);
|
||||
/** Incoming change to topic description: the already mutated topic in memory is synchronized to DB */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean topicUpdate(Topic topic);
|
||||
/** Delete topic */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean topicDelete(Topic topic, boolean hard);
|
||||
|
||||
/** Add subscription in a generic topic. The subscription is received from the server. */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
long subAdd(Topic topic, Subscription sub);
|
||||
/** Update subscription in a generic topic */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean subUpdate(Topic topic, Subscription sub);
|
||||
/** Add a new subscriber to topic. The new subscriber is being added locally. */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
long subNew(Topic topic, Subscription sub);
|
||||
/** Delete existing subscription */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean subDelete(Topic topic, Subscription sub);
|
||||
|
||||
/** Get a list o topic subscriptions from DB. */
|
||||
Collection<Subscription> getSubscriptions(Topic topic);
|
||||
|
||||
/** Read user description */
|
||||
User userGet(String uid);
|
||||
/** Insert new user */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
long userAdd(User user);
|
||||
/** Update existing user */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean userUpdate(User user);
|
||||
|
||||
/**
|
||||
* Message received from the server.
|
||||
*/
|
||||
Message msgReceived(Topic topic, Subscription sub, MsgServerData msg);
|
||||
|
||||
/**
|
||||
* Save message to DB as "sending".
|
||||
*
|
||||
* @param topic topic which sent the message
|
||||
* @param data message data to save
|
||||
* @param head message headers
|
||||
* @return database ID of the message suitable for use in
|
||||
* {@link #msgDelivered(Topic topic, long id, Date timestamp, int seq)}
|
||||
*/
|
||||
Message msgSend(Topic topic, Drafty data, Map<String, Object> head);
|
||||
|
||||
/**
|
||||
* Save message to database as a draft. Draft will not be sent to server until it status changes.
|
||||
*
|
||||
* @param topic topic which sent the message
|
||||
* @param data message data to save
|
||||
* @param head message headers
|
||||
* @return database ID of the message suitable for use in
|
||||
* {@link #msgDelivered(Topic topic, long id, Date timestamp, int seq)}
|
||||
*/
|
||||
Message msgDraft(Topic topic, Drafty data, Map<String, Object> head);
|
||||
|
||||
/**
|
||||
* Update message draft content without
|
||||
*
|
||||
* @param topic topic which sent the message
|
||||
* @param dbMessageId database ID of the message.
|
||||
* @param data updated content of the message. Must not be null.
|
||||
* @return true on success, false otherwise
|
||||
*/
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgDraftUpdate(Topic topic, long dbMessageId, Drafty data);
|
||||
|
||||
/**
|
||||
* Message is ready to be sent to the server.
|
||||
*
|
||||
* @param topic topic which sent the message
|
||||
* @param dbMessageId database ID of the message.
|
||||
* @param data updated content of the message. If null only status is updated.
|
||||
* @return true on success, false otherwise
|
||||
*/
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgReady(Topic topic, long dbMessageId, Drafty data);
|
||||
|
||||
/**
|
||||
* Message is being sent to the server.
|
||||
* @param topic topic which sent the message
|
||||
* @param dbMessageId database ID of the message.
|
||||
* @param sync true when the sync started, false when it's finished unsuccessfully.
|
||||
* @return true on success, false otherwise
|
||||
*
|
||||
*/
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgSyncing(Topic topic, long dbMessageId, boolean sync);
|
||||
|
||||
/**
|
||||
* Failed to form or send message.
|
||||
*
|
||||
* @param topic topic which sent the message
|
||||
* @param dbMessageId database ID of the message.
|
||||
* @return true on success, false otherwise
|
||||
*/
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgFailed(Topic topic, long dbMessageId);
|
||||
|
||||
/**
|
||||
* Delete all failed messages in the given topis.
|
||||
*
|
||||
* @param topic topic which sent the message
|
||||
* @return true on success, false otherwise
|
||||
*/
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgPruneFailed(Topic topic);
|
||||
|
||||
/**
|
||||
* Remove message by database id.
|
||||
*/
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgDiscard(Topic topic, long dbMessageId);
|
||||
|
||||
/**
|
||||
* Remove message by seq ID.
|
||||
*/
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgDiscardSeq(Topic topic, int seq);
|
||||
|
||||
/**
|
||||
* Message delivered to the server and received a real seq ID.
|
||||
*
|
||||
* @param topic topic which sent the message.
|
||||
* @param dbMessageId database ID of the message.
|
||||
* @param timestamp server timestamp.
|
||||
* @param seq server-issued message seqId.
|
||||
* @return true on success, false otherwise *
|
||||
*/
|
||||
boolean msgDelivered(Topic topic, long dbMessageId, Date timestamp, int seq);
|
||||
/** Mark messages for deletion by range */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgMarkToDelete(Topic topic, int fromId, int toId, boolean markAsHard);
|
||||
/** Mark messages for deletion by seq ID list */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgMarkToDelete(Topic topic, MsgRange[] ranges, boolean markAsHard);
|
||||
/** Delete messages */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgDelete(Topic topic, int delId, int fromId, int toId);
|
||||
/** Delete messages */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgDelete(Topic topic, int delId, MsgRange[] ranges);
|
||||
/** Set recv value for a given subscriber */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgRecvByRemote(Subscription sub, int recv);
|
||||
/** Set read value for a given subscriber */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean msgReadByRemote(Subscription sub, int read);
|
||||
|
||||
/**
|
||||
* Returns message ranges present in DB.
|
||||
*
|
||||
* @param topic topic to query.
|
||||
* @param ranges message ranges to test for presence in the local cache.
|
||||
* @return those message ranges which are present in the local cache.
|
||||
*/
|
||||
MsgRange[] msgIsCached(Topic topic, MsgRange[] ranges);
|
||||
|
||||
/** Get seq IDs of the stored messages as a MsgRange, inclusive-exclusive [low, hi) */
|
||||
MsgRange getCachedMessagesRange(Topic topic);
|
||||
/**
|
||||
* Get the ranges of the messages missing in cache, inclusive-exclusive [low, hi).
|
||||
* Returns empty array if all messages are present or no messages are found.
|
||||
*/
|
||||
MsgRange[] getMissingRanges(Topic topic, int startFrom, int pageSize, boolean newer);
|
||||
/** Local user reported messages as read */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean setRead(Topic topic, int read);
|
||||
/** Local user reported messages as received */
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
boolean setRecv(Topic topic, int recv);
|
||||
|
||||
/** Retrieve a single message by database id */
|
||||
<T extends Message> T getMessageById(long dbMessageId);
|
||||
|
||||
/**
|
||||
* Retrieve a single message preview by database id.
|
||||
*/
|
||||
<T extends Message> T getMessagePreviewById(long dbMessageId);
|
||||
|
||||
/**
|
||||
* Get seq IDs of up to limit versions of the edited message with the given ID.
|
||||
* @param topic topic which sent the message.
|
||||
* @param seq ID of the edited message to get versions of.
|
||||
* @param limit the count of latest versions to get or all if limit is zero.
|
||||
* @return array of seq ID of edits ordered from newest to oldest.
|
||||
*/
|
||||
int[] getAllMsgVersions(Topic topic, int seq, int limit);
|
||||
|
||||
/**
|
||||
* Get the latest message in each topic. Caller must close the result after use.
|
||||
*/
|
||||
<T extends Iterator<Message> & Closeable> T getLatestMessagePreviews();
|
||||
|
||||
/** Get a list of unsent messages. Close the result after use. */
|
||||
<T extends Iterator<Message> & Closeable> T getQueuedMessages(Topic topic);
|
||||
|
||||
/**
|
||||
* Get a list of pending delete message ranges.
|
||||
* @param topic topic where the messages were deleted.
|
||||
* @param hard set to <b>true</b> to fetch hard-deleted messages, soft-deleted otherwise.
|
||||
*/
|
||||
MsgRange[] getQueuedMessageDeletes(Topic topic, boolean hard);
|
||||
|
||||
/**
|
||||
* Retrieve a single message by topic and seq ID.
|
||||
*/
|
||||
<T extends Message> T getMessageBySeq(Topic topic, int seq);
|
||||
|
||||
interface Message {
|
||||
String getTopic();
|
||||
|
||||
/** Get message headers */
|
||||
Map<String, Object> getHead();
|
||||
Object getHeader(String key);
|
||||
String getStringHeader(String key);
|
||||
Integer getIntHeader(String key);
|
||||
|
||||
/** Get message payload */
|
||||
Drafty getContent();
|
||||
/** Set message payload */
|
||||
void setContent(Drafty content);
|
||||
|
||||
/** Get current message unique ID (database ID) */
|
||||
long getDbId();
|
||||
|
||||
/** Get Tinode seq Id of the message (different from database ID */
|
||||
int getSeqId();
|
||||
|
||||
/** Get delivery status */
|
||||
int getStatus();
|
||||
|
||||
boolean isMine();
|
||||
boolean isPending();
|
||||
boolean isReady();
|
||||
boolean isDeleted();
|
||||
boolean isDeleted(boolean hard);
|
||||
boolean isSynced();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import co.tinode.tinodesdk.model.Description;
|
||||
import co.tinode.tinodesdk.model.Mergeable;
|
||||
import co.tinode.tinodesdk.model.Subscription;
|
||||
|
||||
/**
|
||||
* Information about specific user
|
||||
*/
|
||||
public class User<P> implements LocalData {
|
||||
|
||||
public Date updated;
|
||||
public String uid;
|
||||
public P pub;
|
||||
|
||||
private Payload mLocal = null;
|
||||
|
||||
public User() {
|
||||
}
|
||||
|
||||
public User(String uid) {
|
||||
this.uid = uid;
|
||||
}
|
||||
|
||||
public User(Subscription<P,?> sub) {
|
||||
if (sub.user != null && !sub.user.isEmpty()) {
|
||||
uid = sub.user;
|
||||
updated = sub.updated;
|
||||
pub = sub.pub;
|
||||
} else {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
|
||||
public User(String uid, Description<P,?> desc) {
|
||||
this.uid = uid;
|
||||
updated = desc.updated;
|
||||
try {
|
||||
pub = desc.pub;
|
||||
} catch (ClassCastException ignored) {}
|
||||
}
|
||||
|
||||
private boolean mergePub(P pub) {
|
||||
boolean changed = false;
|
||||
if (pub != null) {
|
||||
try {
|
||||
if (Tinode.isNull(pub)) {
|
||||
this.pub = null;
|
||||
changed = true;
|
||||
} else if (this.pub != null && (this.pub instanceof Mergeable)) {
|
||||
changed = ((Mergeable) this.pub).merge((Mergeable) pub);
|
||||
} else {
|
||||
this.pub = pub;
|
||||
changed = true;
|
||||
}
|
||||
} catch (ClassCastException ignored) { }
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public boolean merge(User<P> user) {
|
||||
boolean changed = false;
|
||||
|
||||
if ((user.updated != null) && (updated == null || updated.before(user.updated))) {
|
||||
updated = user.updated;
|
||||
changed = mergePub(user.pub);
|
||||
} else if (pub == null && user.pub != null) {
|
||||
pub = user.pub;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
public boolean merge(Subscription<P,?> sub) {
|
||||
boolean changed = false;
|
||||
|
||||
if ((sub.updated != null) && (updated == null || updated.before(sub.updated))) {
|
||||
updated = sub.updated;
|
||||
changed = mergePub(sub.pub);
|
||||
} else if (pub == null && sub.pub != null) {
|
||||
pub = sub.pub;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
public boolean merge(Description<P,?> desc) {
|
||||
boolean changed = false;
|
||||
|
||||
if ((desc.updated != null) && (updated == null || updated.before(desc.updated))) {
|
||||
updated = desc.updated;
|
||||
changed = mergePub(desc.pub);
|
||||
} else if (pub == null && desc.pub != null) {
|
||||
pub = desc.pub;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setLocal(Payload value) {
|
||||
mLocal = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Payload getLocal() {
|
||||
return mLocal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
public class AccessChange implements Serializable {
|
||||
public String want;
|
||||
public String given;
|
||||
|
||||
public AccessChange() {
|
||||
}
|
||||
|
||||
public AccessChange(String want, String given) {
|
||||
this.want = want;
|
||||
this.given = given;
|
||||
}
|
||||
|
||||
@SuppressWarnings("NullableProblems")
|
||||
@Override
|
||||
public String toString() {
|
||||
return "{\"given\":" + (given != null ? " \"" + given + "\"" : " null") +
|
||||
", \"want\":" + (want != null ? " \"" + want + "\"" : " null}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Access mode.
|
||||
*/
|
||||
public class Acs implements Serializable {
|
||||
public enum Side {
|
||||
MODE(0), WANT(1), GIVEN(2);
|
||||
|
||||
private final int val;
|
||||
Side(int val) {
|
||||
this.val = val;
|
||||
}
|
||||
|
||||
public int val() {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
AcsHelper given = null;
|
||||
AcsHelper want = null;
|
||||
AcsHelper mode = null;
|
||||
|
||||
public Acs() {
|
||||
assign(null, null, null);
|
||||
}
|
||||
|
||||
public Acs(String g, String w) {
|
||||
assign(g, w, null);
|
||||
}
|
||||
|
||||
public Acs(String g, String w, String m) {
|
||||
assign(g, w, m);
|
||||
}
|
||||
|
||||
public Acs(Acs am) {
|
||||
if (am != null) {
|
||||
given = am.given != null ? new AcsHelper(am.given) : null;
|
||||
want = am.want != null ? new AcsHelper(am.want) : null;
|
||||
mode = am.mode != null ? new AcsHelper(am.mode) : AcsHelper.and(want, given);
|
||||
}
|
||||
}
|
||||
|
||||
public Acs(Map<String,String> am) {
|
||||
if (am != null) {
|
||||
assign(am.get("given"), am.get("want"), am.get("mode"));
|
||||
}
|
||||
}
|
||||
|
||||
public Acs(AccessChange ac) {
|
||||
if (ac != null) {
|
||||
boolean change = false;
|
||||
if (ac.given != null) {
|
||||
if (given == null) {
|
||||
given = new AcsHelper();
|
||||
}
|
||||
change = given.update(ac.given);
|
||||
}
|
||||
|
||||
if (ac.want != null) {
|
||||
if (want == null) {
|
||||
want = new AcsHelper();
|
||||
}
|
||||
change = change || want.update(ac.want);
|
||||
}
|
||||
|
||||
if (change) {
|
||||
mode = AcsHelper.and(want, given);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void assign(String g, String w, String m) {
|
||||
this.given = g != null ? new AcsHelper(g) : null;
|
||||
this.want = w != null ? new AcsHelper(w) : null;
|
||||
this.mode = m != null ? new AcsHelper(m) : AcsHelper.and(want, given);
|
||||
}
|
||||
|
||||
public void setMode(String m) {
|
||||
mode = m != null ? new AcsHelper(m) : null;
|
||||
}
|
||||
public String getMode() {
|
||||
return mode != null ? mode.toString() : null;
|
||||
}
|
||||
public AcsHelper getModeHelper() {
|
||||
return new AcsHelper(mode);
|
||||
}
|
||||
|
||||
public void setGiven(String g) {
|
||||
given = g != null ? new AcsHelper(g) : null;
|
||||
}
|
||||
public String getGiven() {
|
||||
return given != null ? given.toString() : null;
|
||||
}
|
||||
public AcsHelper getGivenHelper() {
|
||||
return new AcsHelper(given);
|
||||
}
|
||||
|
||||
public void setWant(String w) {
|
||||
want = w != null ? new AcsHelper(w) : null;
|
||||
}
|
||||
public String getWant() {
|
||||
return want != null ? want.toString() : null;
|
||||
}
|
||||
public AcsHelper getWantHelper() {
|
||||
return new AcsHelper(want);
|
||||
}
|
||||
|
||||
public boolean merge(Acs am) {
|
||||
int change = 0;
|
||||
if (am != null && !equals(am)) {
|
||||
if (am.given != null) {
|
||||
if (given == null) {
|
||||
given = new AcsHelper();
|
||||
}
|
||||
change += given.merge(am.given) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (am.want != null) {
|
||||
if (want == null) {
|
||||
want = new AcsHelper();
|
||||
}
|
||||
change += want.merge(am.want) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (am.mode != null) {
|
||||
if (mode == null) {
|
||||
mode = new AcsHelper();
|
||||
}
|
||||
change += mode.merge(am.mode) ? 1 : 0;
|
||||
} else if (change > 0) {
|
||||
AcsHelper m2 = AcsHelper.and(want, given);
|
||||
if (m2 != null && !m2.equals(mode)) {
|
||||
change ++;
|
||||
mode = m2;
|
||||
}
|
||||
}
|
||||
}
|
||||
return change > 0;
|
||||
}
|
||||
|
||||
public boolean merge(Map<String,String> am) {
|
||||
int change = 0;
|
||||
if (am != null) {
|
||||
if (am.get("given") != null) {
|
||||
change += given.merge(new AcsHelper(am.get("given"))) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (am.get("want") != null) {
|
||||
change += want.merge(new AcsHelper(am.get("want"))) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (am.get("mode") != null) {
|
||||
change += mode.merge(new AcsHelper(am.get("mode"))) ? 1 : 0;
|
||||
} else if (change > 0) {
|
||||
AcsHelper m2 = AcsHelper.and(want, given);
|
||||
if (m2 != null && !m2.equals(mode)) {
|
||||
change ++;
|
||||
mode = m2;
|
||||
}
|
||||
}
|
||||
}
|
||||
return change > 0;
|
||||
}
|
||||
|
||||
public boolean update(AccessChange ac) {
|
||||
int change = 0;
|
||||
if (ac != null) {
|
||||
try {
|
||||
if (ac.given != null) {
|
||||
if (given == null) {
|
||||
given = new AcsHelper();
|
||||
}
|
||||
change += given.update(ac.given) ? 1 : 0;
|
||||
}
|
||||
if (ac.want != null) {
|
||||
if (want == null) {
|
||||
want = new AcsHelper();
|
||||
}
|
||||
change += want.update(ac.want) ? 1 : 0;
|
||||
}
|
||||
} catch (IllegalArgumentException ignore) {}
|
||||
|
||||
if (change > 0) {
|
||||
AcsHelper m2 = AcsHelper.and(want, given);
|
||||
if (m2 != null && !m2.equals(mode)) {
|
||||
mode = m2;
|
||||
}
|
||||
}
|
||||
}
|
||||
return change > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare this Acs with another.
|
||||
* @param am Acs instance to compare to.
|
||||
* @return true if am represents the same access rights, false otherwise.
|
||||
*/
|
||||
public boolean equals(Acs am) {
|
||||
return (am != null) &&
|
||||
((mode == null && am.mode == null) || (mode != null && mode.equals(am.mode))) &&
|
||||
((want == null && am.want == null) || (want != null && want.equals(am.want))) &&
|
||||
((given == null && am.given == null) || (given != null && given.equals(am.given)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mode is NONE: no flags are set.
|
||||
* @return true if no flags are set.
|
||||
*/
|
||||
public boolean isNone() {
|
||||
return mode != null && mode.isNone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mode Reader (R) flag is set.
|
||||
* @return true if flag is set.
|
||||
*/
|
||||
public boolean isReader() {
|
||||
return mode != null && mode.isReader();
|
||||
}
|
||||
/**
|
||||
* Check if Reader (R) flag is set for the given side.
|
||||
* @return true if flag is set.
|
||||
*/
|
||||
public boolean isReader(Side s) {
|
||||
return switch (s) {
|
||||
case MODE -> mode != null && mode.isReader();
|
||||
case WANT -> want != null && want.isReader();
|
||||
case GIVEN -> given != null && given.isReader();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Writer (W) flag is set.
|
||||
* @return true if flag is set.
|
||||
*/
|
||||
public boolean isWriter() {
|
||||
return mode != null && mode.isWriter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Presence (P) flag is NOT set.
|
||||
* @return true if flag is NOT set.
|
||||
*/
|
||||
public boolean isMuted() {
|
||||
return mode != null && mode.isMuted();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Acs setMuted(boolean v) {
|
||||
if (mode == null) {
|
||||
mode = new AcsHelper("N");
|
||||
}
|
||||
mode.setMuted(v);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Approver (A) flag is set.
|
||||
* @return true if flag is set.
|
||||
*/
|
||||
public boolean isApprover() {
|
||||
return mode != null && mode.isApprover();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if either Owner (O) or Approver (A) flag is set.
|
||||
* @return true if flag is set.
|
||||
*/
|
||||
public boolean isManager() {
|
||||
return mode != null && (mode.isApprover() || mode.isOwner());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Sharer (S) flag is set.
|
||||
* @return true if flag is set.
|
||||
*/
|
||||
public boolean isSharer() {
|
||||
return mode != null && mode.isSharer();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if Deleter (D) flag is set.
|
||||
* @return true if flag is set.
|
||||
*/
|
||||
public boolean isDeleter() {
|
||||
return mode != null && mode.isDeleter();
|
||||
}
|
||||
/**
|
||||
* Check if Owner (O) flag is set.
|
||||
* @return true if flag is set.
|
||||
*/
|
||||
public boolean isOwner() {
|
||||
return mode != null && mode.isOwner();
|
||||
}
|
||||
/**
|
||||
* Check if Joiner (J) flag is set.
|
||||
* @return true if flag is set.
|
||||
*/
|
||||
public boolean isJoiner() {
|
||||
return mode != null && mode.isJoiner();
|
||||
}
|
||||
/**
|
||||
* Check if Joiner (J) flag is set for the specified side.
|
||||
* @param s site to query (mode, want, given).
|
||||
* @return true if flag is set.
|
||||
*/
|
||||
public boolean isJoiner(Side s) {
|
||||
return switch (s) {
|
||||
case MODE -> mode != null && mode.isJoiner();
|
||||
case WANT -> want != null && want.isJoiner();
|
||||
case GIVEN -> given != null && given.isJoiner();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mode is defined.
|
||||
* @return true if defined.
|
||||
*/
|
||||
public boolean isModeDefined() {
|
||||
return mode != null && mode.isDefined();
|
||||
}
|
||||
/**
|
||||
* Check if given is defined.
|
||||
* @return true if defined.
|
||||
*/
|
||||
public boolean isGivenDefined() {
|
||||
return given != null && given.isDefined();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if want is defined.
|
||||
* @return true if defined.
|
||||
*/
|
||||
public boolean isWantDefined() {
|
||||
return want != null && want.isDefined();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mode is invalid.
|
||||
* @return true if invalid.
|
||||
*/
|
||||
public boolean isInvalid() {
|
||||
return mode != null && mode.isInvalid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permissions present in 'want' but missing in 'given'.
|
||||
* Inverse of {@link Acs#getExcessive}
|
||||
*
|
||||
* @return <b>want</b> value.
|
||||
*/
|
||||
@JsonIgnore
|
||||
public AcsHelper getMissing() {
|
||||
return AcsHelper.diff(want, given);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permissions present in 'given' but missing in 'want'.
|
||||
* Inverse of {@link Acs#getMissing}
|
||||
*
|
||||
* @return permissions present in <b>given</b> but missing in <b>want</b>.
|
||||
*/
|
||||
@JsonIgnore
|
||||
public AcsHelper getExcessive() {
|
||||
return AcsHelper.diff(given, want);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "{\"given\":" + (given != null ? " \"" + given + "\"" : " null") +
|
||||
", \"want\":" + (want != null ? " \"" + want + "\"" : " null") +
|
||||
", \"mode\":" + (mode != null ? " \"" + mode + "\"}" : " null}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
* Helper class for access mode parser/generator.
|
||||
*/
|
||||
public class AcsHelper implements Serializable {
|
||||
// User access to topic
|
||||
private static final int MODE_JOIN = 0x01; // J - join topic
|
||||
private static final int MODE_READ = 0x02; // R - read broadcasts
|
||||
private static final int MODE_WRITE = 0x04; // W - publish
|
||||
private static final int MODE_PRES = 0x08; // P - receive presence notifications
|
||||
private static final int MODE_APPROVE = 0x10; // A - approve requests
|
||||
private static final int MODE_SHARE = 0x20; // S - user can invite other people to join (S)
|
||||
private static final int MODE_DELETE = 0x40; // D - user can hard-delete messages (D), only owner can completely delete
|
||||
private static final int MODE_OWNER = 0x80; // O - user is the owner (O) - full access
|
||||
|
||||
private static final int MODE_NONE = 0; // No access, requests to gain access are processed normally (N)
|
||||
|
||||
// Invalid mode to indicate an error
|
||||
private static final int MODE_INVALID = 0x100000;
|
||||
|
||||
private int a;
|
||||
|
||||
public AcsHelper() {
|
||||
a = MODE_NONE;
|
||||
}
|
||||
|
||||
public AcsHelper(String str) {
|
||||
a = decode(str);
|
||||
}
|
||||
|
||||
public AcsHelper(AcsHelper ah) {
|
||||
a = ah != null ? ah.a : MODE_INVALID;
|
||||
}
|
||||
|
||||
public AcsHelper(Integer a) {
|
||||
this.a = a != null ? a : MODE_INVALID;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return encode(a);
|
||||
}
|
||||
|
||||
public boolean update(String umode) {
|
||||
int old = a;
|
||||
a = update(a, umode);
|
||||
return a != old;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
|
||||
if (o == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (o == this) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(o instanceof AcsHelper ah)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return a == ah.a;
|
||||
}
|
||||
|
||||
public boolean equals(String s) {
|
||||
return a == decode(s);
|
||||
}
|
||||
|
||||
public boolean isNone() {
|
||||
return a == MODE_NONE;
|
||||
}
|
||||
public boolean isReader() {
|
||||
return (a & MODE_READ) != 0;
|
||||
}
|
||||
public boolean isWriter() {
|
||||
return (a & MODE_WRITE) != 0;
|
||||
}
|
||||
public boolean isMuted() {
|
||||
return (a & MODE_PRES) == 0;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void setMuted(boolean v) {
|
||||
if (a == MODE_INVALID) {
|
||||
a = MODE_NONE;
|
||||
}
|
||||
a = !v ? (a | MODE_PRES) : (a & ~MODE_PRES);
|
||||
}
|
||||
public boolean isApprover() {
|
||||
return (a & MODE_APPROVE) != 0;
|
||||
}
|
||||
|
||||
public boolean isSharer() {
|
||||
return (a & MODE_SHARE) != 0;
|
||||
}
|
||||
|
||||
public boolean isDeleter() {
|
||||
return (a & MODE_DELETE) != 0;
|
||||
}
|
||||
|
||||
public boolean isOwner() {
|
||||
return (a & MODE_OWNER) != 0;
|
||||
}
|
||||
|
||||
public boolean isJoiner() {
|
||||
return (a & MODE_JOIN) != 0;
|
||||
}
|
||||
|
||||
public boolean isDefined() {
|
||||
return a != MODE_INVALID;
|
||||
}
|
||||
public boolean isInvalid() {
|
||||
return a == MODE_INVALID;
|
||||
}
|
||||
|
||||
private static int decode(String mode) {
|
||||
if (mode == null || mode.isEmpty()) {
|
||||
return MODE_INVALID;
|
||||
}
|
||||
|
||||
int m0 = MODE_NONE;
|
||||
|
||||
for (char c : mode.toCharArray()) {
|
||||
switch (c) {
|
||||
case 'J':
|
||||
case 'j':
|
||||
m0 |= MODE_JOIN;
|
||||
continue;
|
||||
case 'R':
|
||||
case 'r':
|
||||
m0 |= MODE_READ;
|
||||
continue;
|
||||
case 'W':
|
||||
case 'w':
|
||||
m0 |= MODE_WRITE;
|
||||
continue;
|
||||
case 'A':
|
||||
case 'a':
|
||||
m0 |= MODE_APPROVE;
|
||||
continue;
|
||||
case 'S':
|
||||
case 's':
|
||||
m0 |= MODE_SHARE;
|
||||
continue;
|
||||
case 'D':
|
||||
case 'd':
|
||||
m0 |= MODE_DELETE;
|
||||
continue;
|
||||
case 'P':
|
||||
case 'p':
|
||||
m0 |= MODE_PRES;
|
||||
continue;
|
||||
case 'O':
|
||||
case 'o':
|
||||
m0 |= MODE_OWNER;
|
||||
continue;
|
||||
case 'N':
|
||||
case 'n':
|
||||
return MODE_NONE;
|
||||
default:
|
||||
return MODE_INVALID;
|
||||
}
|
||||
}
|
||||
|
||||
return m0;
|
||||
}
|
||||
|
||||
private static String encode(Integer val) {
|
||||
// Need to distinguish between "not set" and "no access"
|
||||
if (val == null || val == MODE_INVALID) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (val == MODE_NONE) {
|
||||
return "N";
|
||||
}
|
||||
|
||||
StringBuilder res = new StringBuilder(6);
|
||||
char[] modes = new char[]{'J', 'R', 'W', 'P', 'A', 'S', 'D', 'O'};
|
||||
for (int i = 0; i < modes.length; i++) {
|
||||
if ((val & (1 << i)) != 0) {
|
||||
res.append(modes[i]);
|
||||
}
|
||||
}
|
||||
return res.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply changes, defined as a string, to the given internal representation.
|
||||
*
|
||||
* @param val value to change.
|
||||
* @param umode change to the value, '+' or '-' followed by the letter(s) being set or unset,
|
||||
* or an explicit new value: "+JS-WR" or just "JSA"
|
||||
* @return updated value.
|
||||
*/
|
||||
private static int update(int val, String umode) {
|
||||
if (umode == null || umode.isEmpty()) {
|
||||
return val;
|
||||
}
|
||||
|
||||
int m0;
|
||||
char action = umode.charAt(0);
|
||||
if (action == '+' || action == '-') {
|
||||
int val0 = val;
|
||||
StringTokenizer parts = new StringTokenizer(umode, "-+", true);
|
||||
while (parts.hasMoreTokens()) {
|
||||
action = parts.nextToken().charAt(0);
|
||||
if (parts.hasMoreTokens()) {
|
||||
m0 = decode(parts.nextToken());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if (m0 == MODE_INVALID) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
if (m0 == MODE_NONE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action == '+') {
|
||||
val0 |= m0;
|
||||
} else if (action == '-') {
|
||||
val0 &= ~m0;
|
||||
}
|
||||
}
|
||||
val = val0;
|
||||
|
||||
} else {
|
||||
val = decode(umode);
|
||||
if (val == MODE_INVALID) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
public boolean merge(AcsHelper ah) {
|
||||
if (ah != null && ah.a != MODE_INVALID) {
|
||||
if (ah.a != a) {
|
||||
a = ah.a;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitwise AND between two modes, usually given & want: a1 & a2.
|
||||
* @param a1 first mode
|
||||
* @param a2 second mode
|
||||
* @return {AcsHelper} (a1 & a2)
|
||||
*/
|
||||
public static AcsHelper and(AcsHelper a1, AcsHelper a2) {
|
||||
if (a1 != null && !a1.isInvalid() && a2 != null && !a2.isInvalid()) {
|
||||
return new AcsHelper(a1.a & a2.a);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bits present in a1 but missing in a2: a1 & ~a2.
|
||||
* @param a1 first mode
|
||||
* @param a2 second mode
|
||||
* @return {AcsHelper} (a1 & ~a2)
|
||||
*/
|
||||
public static AcsHelper diff(AcsHelper a1, AcsHelper a2) {
|
||||
if (a1 != null && !a1.isInvalid() && a2 != null && !a2.isInvalid()) {
|
||||
return new AcsHelper(a1.a & ~a2.a);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.core.Base64Variants;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
* Helper of authentication scheme for account creation.
|
||||
*/
|
||||
public record AuthScheme(String scheme, String secret) implements Serializable {
|
||||
public static final String LOGIN_BASIC = "basic";
|
||||
public static final String LOGIN_TOKEN = "token";
|
||||
public static final String LOGIN_RESET = "reset";
|
||||
public static final String LOGIN_CODE = "code";
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return scheme + ":" + secret;
|
||||
}
|
||||
|
||||
public static AuthScheme parse(String s) {
|
||||
if (s != null) {
|
||||
StringTokenizer st = new StringTokenizer(s, ":");
|
||||
if (st.countTokens() == 2) {
|
||||
String scheme = st.nextToken();
|
||||
if (scheme.contentEquals(LOGIN_BASIC) || scheme.contentEquals(LOGIN_TOKEN)) {
|
||||
return new AuthScheme(scheme, st.nextToken());
|
||||
}
|
||||
} else {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String encodeBasicToken(String uname, String password) {
|
||||
uname = uname == null ? "" : uname;
|
||||
// Encode string as base64
|
||||
if (uname.contains(":")) {
|
||||
throw new IllegalArgumentException("illegal character ':' in user name '" + uname + "'");
|
||||
}
|
||||
password = password == null ? "" : password;
|
||||
return Base64Variants.getDefaultVariant().encode((uname + ":" + password).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public static String encodeResetSecret(String scheme, String method, String value) {
|
||||
// Join parts using ":" then base64-encode.
|
||||
if (scheme == null || method == null || value == null) {
|
||||
throw new IllegalArgumentException("illegal 'null' parameter");
|
||||
}
|
||||
if (scheme.contains(":") || method.contains(":") || value.contains(":")) {
|
||||
throw new IllegalArgumentException("illegal character ':' in parameter");
|
||||
}
|
||||
return Base64Variants.getDefaultVariant().encode((scheme + ":" + method + ":" + value)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public static String[] decodeBasicToken(String token) {
|
||||
String basicToken;
|
||||
// Decode base64 string
|
||||
basicToken = new String(Base64Variants.getDefaultVariant().decode(token), StandardCharsets.UTF_8);
|
||||
|
||||
// Split "login:password" into parts.
|
||||
int splitAt = basicToken.indexOf(':');
|
||||
if (splitAt <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new String[]{
|
||||
basicToken.substring(0, splitAt),
|
||||
splitAt == basicToken.length() - 1 ? "" : basicToken.substring(splitAt + 1, basicToken.length() - 1)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public static AuthScheme basicInstance(String login, String password) {
|
||||
return new AuthScheme(LOGIN_BASIC, encodeBasicToken(login, password));
|
||||
}
|
||||
|
||||
public static AuthScheme tokenInstance(String secret) {
|
||||
return new AuthScheme(LOGIN_TOKEN, secret);
|
||||
}
|
||||
|
||||
public static AuthScheme codeInstance(String code, String method, String value) {
|
||||
// The secret is structured as <code>:<cred_method>:<cred_value>, "123456:email:alice@example.com".
|
||||
return new AuthScheme(LOGIN_CODE, encodeResetSecret(code, method, value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Client message:
|
||||
* <p>
|
||||
* Hi *MsgClientHi `json:"hi"`
|
||||
* Acc *MsgClientAcc `json:"acc"`
|
||||
* Login *MsgClientLogin `json:"login"`
|
||||
* Sub *MsgClientSub `json:"sub"`
|
||||
* Leave *MsgClientLeave `json:"leave"`
|
||||
* Pub *MsgClientPub `json:"pub"`
|
||||
* Get *MsgClientGet `json:"get"`
|
||||
* Set *MsgClientSet `json:"set"`
|
||||
* Del *MsgClientDel `json:"del"`
|
||||
* Note *MsgClientNote `json:"note"`
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class ClientMessage<Pu,Pr> implements Serializable {
|
||||
public MsgClientHi hi;
|
||||
public MsgClientAcc<Pu,Pr> acc;
|
||||
public MsgClientLogin login;
|
||||
public MsgClientSub sub;
|
||||
public MsgClientLeave leave;
|
||||
public MsgClientPub pub;
|
||||
public MsgClientGet get;
|
||||
public MsgClientSet set;
|
||||
public MsgClientDel del;
|
||||
public MsgClientNote note;
|
||||
// Optional data.
|
||||
public MsgClientExtra extra;
|
||||
|
||||
public ClientMessage() {
|
||||
}
|
||||
public ClientMessage(MsgClientHi hi) {
|
||||
this.hi = hi;
|
||||
}
|
||||
public ClientMessage(MsgClientAcc<Pu,Pr> acc) {
|
||||
this.acc = acc;
|
||||
}
|
||||
public ClientMessage(MsgClientLogin login) {
|
||||
this.login = login;
|
||||
}
|
||||
public ClientMessage(MsgClientSub sub) {
|
||||
this.sub = sub;
|
||||
}
|
||||
public ClientMessage(MsgClientLeave leave) {
|
||||
this.leave = leave;
|
||||
}
|
||||
public ClientMessage(MsgClientPub pub) {
|
||||
this.pub = pub;
|
||||
}
|
||||
public ClientMessage(MsgClientGet get) {
|
||||
this.get = get;
|
||||
}
|
||||
public ClientMessage(MsgClientSet set) {
|
||||
this.set = set;
|
||||
}
|
||||
public ClientMessage(MsgClientDel del) {
|
||||
this.del = del;
|
||||
}
|
||||
public ClientMessage(MsgClientNote note) {
|
||||
this.note = note;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Account credential: email, phone, captcha
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class Credential implements Comparable<Credential>, Serializable {
|
||||
public static final String METH_EMAIL = "email";
|
||||
public static final String METH_PHONE = "tel";
|
||||
|
||||
// Confirmation method: email, phone, captcha.
|
||||
public String meth;
|
||||
// Credential to be validated, e.g. email or a phone number.
|
||||
public String val;
|
||||
// Confirmation response, such as '123456'.
|
||||
public String resp;
|
||||
// Confirmation parameters.
|
||||
public Object params;
|
||||
// Indicator if credential is validated.
|
||||
public Boolean done;
|
||||
|
||||
public static Credential[] append(@Nullable Credential[] creds, @NotNull Credential c) {
|
||||
if (creds == null) {
|
||||
creds = new Credential[1];
|
||||
} else {
|
||||
creds = Arrays.copyOf(creds, creds.length + 1);
|
||||
}
|
||||
creds[creds.length - 1] = c;
|
||||
|
||||
return creds;
|
||||
}
|
||||
|
||||
public Credential() {
|
||||
}
|
||||
|
||||
public Credential(String meth, String val) {
|
||||
this.meth = meth;
|
||||
this.val = val;
|
||||
}
|
||||
|
||||
public Credential(String meth, String val, String resp, Object params) {
|
||||
this(meth, val);
|
||||
this.resp = resp;
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
public boolean isDone() {
|
||||
return done != null && done;
|
||||
}
|
||||
|
||||
@SuppressWarnings("NullableProblems")
|
||||
@Override
|
||||
public String toString() {
|
||||
return meth + ":" + val;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(Credential other) {
|
||||
int r = meth.compareTo(other.meth);
|
||||
if (r ==0) {
|
||||
r = val.compareTo(other.val);
|
||||
}
|
||||
if (r == 0) {
|
||||
r = done.compareTo(other.done);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Class describing default access to topic
|
||||
*/
|
||||
public class Defacs implements Serializable {
|
||||
public AcsHelper auth;
|
||||
public AcsHelper anon;
|
||||
|
||||
public Defacs() {
|
||||
}
|
||||
|
||||
public Defacs(String auth, String anon) {
|
||||
setAuth(auth);
|
||||
setAnon(anon);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (o == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(o instanceof Defacs rhs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (Objects.equals(auth, rhs.auth)) && (Objects.equals(anon, rhs.anon));
|
||||
}
|
||||
|
||||
public String getAuth() {
|
||||
return auth != null ? auth.toString() : null;
|
||||
}
|
||||
public void setAuth(String a) {
|
||||
auth = new AcsHelper(a);
|
||||
}
|
||||
|
||||
public String getAnon() {
|
||||
return anon != null ? anon.toString() : null;
|
||||
}
|
||||
public void setAnon(String a) {
|
||||
anon = new AcsHelper(a);
|
||||
}
|
||||
|
||||
public boolean merge(Defacs defacs) {
|
||||
int changed = 0;
|
||||
if (defacs.auth != null) {
|
||||
if (auth == null) {
|
||||
auth = defacs.auth;
|
||||
changed ++;
|
||||
} else {
|
||||
changed += auth.merge(defacs.auth) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
if (defacs.anon != null) {
|
||||
if (anon == null) {
|
||||
anon = defacs.anon;
|
||||
changed ++;
|
||||
} else {
|
||||
changed += anon.merge(defacs.anon) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
return changed > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Part of Meta server response
|
||||
*/
|
||||
public class DelValues implements Serializable {
|
||||
public Integer clear;
|
||||
public MsgRange[] delseq;
|
||||
|
||||
public DelValues() {}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
import co.tinode.tinodesdk.Tinode;
|
||||
|
||||
/**
|
||||
* Topic description as deserialized from the server packet.
|
||||
*/
|
||||
public class Description<DP, DR> implements Serializable {
|
||||
public Date created;
|
||||
public Date updated;
|
||||
public Date touched;
|
||||
|
||||
public Boolean online;
|
||||
|
||||
public Defacs defacs;
|
||||
public Acs acs;
|
||||
public int seq;
|
||||
// Values reported by the current user as read and received
|
||||
public int read;
|
||||
public int recv;
|
||||
public int clear;
|
||||
// Merged from Subscription.
|
||||
public int subcnt;
|
||||
|
||||
public boolean chan;
|
||||
|
||||
@JsonProperty("public")
|
||||
public DP pub;
|
||||
@JsonProperty("private")
|
||||
public DR priv;
|
||||
public TrustedType trusted;
|
||||
public LastSeen seen;
|
||||
|
||||
public Description() {
|
||||
}
|
||||
|
||||
private boolean mergePub(DP spub) {
|
||||
boolean changed;
|
||||
if (Tinode.isNull(spub)) {
|
||||
pub = null;
|
||||
changed = true;
|
||||
} else {
|
||||
if (pub != null && (pub instanceof Mergeable)) {
|
||||
changed = ((Mergeable)pub).merge((Mergeable)spub);
|
||||
} else {
|
||||
pub = spub;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private boolean mergePriv(DR spriv) {
|
||||
boolean changed;
|
||||
if (Tinode.isNull(spriv)) {
|
||||
priv = null;
|
||||
changed = true;
|
||||
} else {
|
||||
if (priv != null && (priv instanceof Mergeable)) {
|
||||
changed = ((Mergeable)priv).merge((Mergeable)spriv);
|
||||
} else {
|
||||
priv = spriv;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy non-null values to this object.
|
||||
*
|
||||
* @param desc object to copy.
|
||||
*/
|
||||
public boolean merge(Description<DP,DR> desc) {
|
||||
boolean changed = false;
|
||||
|
||||
if (created == null && desc.created != null) {
|
||||
created = desc.created;
|
||||
changed = true;
|
||||
}
|
||||
if (desc.updated != null && (updated == null || updated.before(desc.updated))) {
|
||||
updated = desc.updated;
|
||||
changed = true;
|
||||
}
|
||||
if (desc.touched != null && (touched == null || touched.before(desc.touched))) {
|
||||
touched = desc.touched;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (chan != desc.chan) {
|
||||
chan = desc.chan;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (desc.defacs != null) {
|
||||
if (defacs == null) {
|
||||
defacs = desc.defacs;
|
||||
changed = true;
|
||||
} else {
|
||||
changed = defacs.merge(desc.defacs) || changed;
|
||||
}
|
||||
}
|
||||
|
||||
if (desc.acs != null) {
|
||||
if (acs == null) {
|
||||
acs = desc.acs;
|
||||
changed = true;
|
||||
} else {
|
||||
changed = acs.merge(desc.acs) || changed;
|
||||
}
|
||||
}
|
||||
|
||||
if (desc.seq > seq) {
|
||||
seq = desc.seq;
|
||||
changed = true;
|
||||
}
|
||||
if (desc.read > read) {
|
||||
read = desc.read;
|
||||
changed = true;
|
||||
}
|
||||
if (desc.recv > recv) {
|
||||
recv = desc.recv;
|
||||
changed = true;
|
||||
}
|
||||
if (desc.clear > clear) {
|
||||
clear = desc.clear;
|
||||
changed = true;
|
||||
}
|
||||
if (desc.subcnt > 0) {
|
||||
changed = subcnt != desc.subcnt || changed;
|
||||
subcnt = desc.subcnt;
|
||||
}
|
||||
if (desc.pub != null) {
|
||||
changed = mergePub(desc.pub) || changed;
|
||||
}
|
||||
|
||||
if (desc.trusted != null) {
|
||||
if (trusted == null) {
|
||||
trusted = new TrustedType();
|
||||
changed = true;
|
||||
}
|
||||
changed = trusted.merge(desc.trusted) || changed;
|
||||
}
|
||||
|
||||
if (desc.priv != null) {
|
||||
changed = mergePriv(desc.priv) || changed;
|
||||
}
|
||||
|
||||
if (desc.online != null && desc.online != online) {
|
||||
online = desc.online;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (desc.seen != null) {
|
||||
if (seen == null) {
|
||||
seen = desc.seen;
|
||||
changed = true;
|
||||
} else {
|
||||
changed = seen.merge(desc.seen) || changed;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge subscription into a description
|
||||
*/
|
||||
public <SP,SR> boolean merge(Subscription<SP,SR> sub) {
|
||||
boolean changed = false;
|
||||
|
||||
if (sub.updated != null && (updated == null || updated.before(sub.updated))) {
|
||||
updated = sub.updated;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (sub.touched != null && (touched == null || touched.before(sub.touched))) {
|
||||
touched = sub.touched;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (sub.acs != null) {
|
||||
if (acs == null) {
|
||||
acs = sub.acs;
|
||||
changed = true;
|
||||
} else {
|
||||
changed = acs.merge(sub.acs) || changed;
|
||||
}
|
||||
}
|
||||
|
||||
if (sub.seq > seq) {
|
||||
seq = sub.seq;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (sub.read > read) {
|
||||
read = sub.read;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (sub.recv > recv) {
|
||||
recv = sub.recv;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (sub.clear > clear) {
|
||||
clear = sub.clear;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (sub.subcnt > 0) {
|
||||
changed = subcnt != sub.subcnt || changed;
|
||||
subcnt = sub.subcnt;
|
||||
}
|
||||
|
||||
if (sub.pub != null) {
|
||||
// This may throw a ClassCastException.
|
||||
// This is intentional behavior to catch cases of wrong assignment.
|
||||
//noinspection unchecked
|
||||
changed = mergePub((DP) sub.pub) || changed;
|
||||
}
|
||||
|
||||
if (sub.trusted != null) {
|
||||
if (trusted == null) {
|
||||
trusted = new TrustedType();
|
||||
changed = true;
|
||||
}
|
||||
changed = trusted.merge(sub.trusted) || changed;
|
||||
}
|
||||
|
||||
if (sub.priv != null) {
|
||||
try {
|
||||
//noinspection unchecked
|
||||
changed = mergePriv((DR)sub.priv) || changed;
|
||||
} catch (ClassCastException ignored) {}
|
||||
|
||||
}
|
||||
|
||||
if (sub.online != null && sub.online != online) {
|
||||
online = sub.online;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (sub.seen != null) {
|
||||
if (seen == null) {
|
||||
seen = sub.seen;
|
||||
changed = true;
|
||||
} else {
|
||||
changed = seen.merge(sub.seen) || changed;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
public boolean merge(MetaSetDesc<DP,DR> desc) {
|
||||
boolean changed = false;
|
||||
|
||||
if (desc.defacs != null) {
|
||||
if (defacs == null) {
|
||||
defacs = desc.defacs;
|
||||
changed = true;
|
||||
} else {
|
||||
changed = defacs.merge(desc.defacs);
|
||||
}
|
||||
}
|
||||
|
||||
if (desc.pub != null) {
|
||||
changed = mergePub(desc.pub) || changed;
|
||||
}
|
||||
|
||||
if (desc.priv != null) {
|
||||
changed = mergePriv(desc.priv) || changed;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Class to hold last seen date-time and User Agent.
|
||||
*/
|
||||
public class LastSeen implements Serializable {
|
||||
public Date when;
|
||||
public String ua;
|
||||
|
||||
public LastSeen() {
|
||||
}
|
||||
|
||||
public LastSeen(Date when) {
|
||||
this.when = when;
|
||||
}
|
||||
|
||||
public LastSeen(Date when, String ua) {
|
||||
this.when = when;
|
||||
this.ua = ua;
|
||||
}
|
||||
|
||||
public boolean merge(LastSeen seen) {
|
||||
boolean changed = false;
|
||||
if (seen != null) {
|
||||
if (seen.when != null && (when == null || when.before(seen.when))) {
|
||||
when = seen.when;
|
||||
ua = seen.ua;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
/*
|
||||
* Interface that allows merging of objects.
|
||||
*/
|
||||
public interface Mergeable {
|
||||
// Merges this with |another|.
|
||||
// Returns the total number of modified fields.
|
||||
boolean merge(Mergeable another);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Parameter of MsgGetMeta
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MetaGetData implements Serializable {
|
||||
// Inclusive (closed): ID >= since.
|
||||
public Integer since;
|
||||
// Exclusive (open): ID < before.
|
||||
public Integer before;
|
||||
public Integer limit;
|
||||
public MsgRange[] ranges;
|
||||
|
||||
public MetaGetData() {}
|
||||
|
||||
public MetaGetData(Integer since, Integer before, Integer limit) {
|
||||
this.since = since;
|
||||
this.before = before;
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
public MetaGetData(MsgRange[] ranges, Integer limit) {
|
||||
this.ranges = ranges;
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "since=" + since + ", before=" + before +
|
||||
", ranges=" + Arrays.toString(ranges) + ", limit=" + limit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Parameter of GetMeta.
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MetaGetDesc implements Serializable {
|
||||
// ims = If modified since...
|
||||
public Date ims;
|
||||
|
||||
public MetaGetDesc() {}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ims=" + ims;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Parameter of MsgGetMeta
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MetaGetSub implements Serializable {
|
||||
public String user;
|
||||
public String topic;
|
||||
public Date ims;
|
||||
public Integer limit;
|
||||
|
||||
public MetaGetSub() {}
|
||||
|
||||
public MetaGetSub(Date ims, Integer limit) {
|
||||
this.ims = ims;
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
public void setUser(String user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public void setTopic(String topic) {
|
||||
this.topic = topic;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "user=[" + user + "]," +
|
||||
" topic=[" + topic + "]," +
|
||||
" ims=[" + ims + "]," +
|
||||
" limit=[" + limit + "]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Topic initiation parameters
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MetaSetDesc<P,R> implements Serializable {
|
||||
public Defacs defacs;
|
||||
@JsonProperty("public")
|
||||
public P pub;
|
||||
@JsonProperty("private")
|
||||
public R priv;
|
||||
|
||||
@JsonIgnore
|
||||
public String[] attachments;
|
||||
|
||||
public MetaSetDesc() {}
|
||||
|
||||
public MetaSetDesc(P pub, R priv, Defacs da) {
|
||||
this.defacs = da;
|
||||
this.pub = pub;
|
||||
this.priv = priv;
|
||||
}
|
||||
|
||||
public MetaSetDesc(P pub, R priv) {
|
||||
this(pub, priv, null);
|
||||
}
|
||||
|
||||
public MetaSetDesc(Defacs da) {
|
||||
this(null, null, da);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Parameter of MsgSetMeta
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MetaSetSub implements Serializable {
|
||||
public String user;
|
||||
public String mode;
|
||||
|
||||
public MetaSetSub() {}
|
||||
|
||||
public MetaSetSub(String mode) {
|
||||
this.user = null;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public MetaSetSub(String user, String mode) {
|
||||
this.user = user;
|
||||
this.mode = mode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Account creation packet
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MsgClientAcc<Pu,Pr> implements Serializable {
|
||||
public final String id;
|
||||
public final String user;
|
||||
public String tmpscheme;
|
||||
public String tmpsecret;
|
||||
public final String scheme;
|
||||
public final String secret;
|
||||
// Use the new account for immediate authentication.
|
||||
public final Boolean login;
|
||||
public String[] tags;
|
||||
public Credential[] cred;
|
||||
// New account parameters
|
||||
public final MetaSetDesc<Pu,Pr> desc;
|
||||
|
||||
public MsgClientAcc(String id, String uid, String scheme, String secret, boolean doLogin,
|
||||
MetaSetDesc<Pu, Pr> desc) {
|
||||
this.id = id;
|
||||
this.user = uid;
|
||||
this.scheme = scheme;
|
||||
this.secret = secret;
|
||||
this.login = doLogin;
|
||||
this.desc = desc;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void addTag(String tag) {
|
||||
if (tags == null) {
|
||||
tags = new String[1];
|
||||
} else {
|
||||
tags = Arrays.copyOf(tags, tags.length + 1);
|
||||
}
|
||||
tags[tags.length-1] = tag;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void addCred(Credential c) {
|
||||
if (cred == null) {
|
||||
cred = new Credential[1];
|
||||
} else {
|
||||
cred = Arrays.copyOf(cred, cred.length + 1);
|
||||
}
|
||||
cred[cred.length-1] = c;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void setTempAuth(String scheme, String secret) {
|
||||
this.tmpscheme = scheme;
|
||||
this.tmpsecret = secret;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import co.tinode.tinodesdk.Tinode;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Topic or message deletion packet
|
||||
* <p>
|
||||
* Id string `json:"id,omitempty"`
|
||||
* Topic string `json:"topic"`
|
||||
* // what to delete, either "msg" to delete messages, "topic" to delete the topic, "sub" to delete subscription
|
||||
* What string `json:"what"`
|
||||
* // Delete messages older than this seq ID (inclusive)
|
||||
* Before int `json:"before"`
|
||||
* // Request to hard-delete messages for all users, if such option is available.
|
||||
* Hard bool `json:"hard,omitempty"`
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MsgClientDel implements Serializable {
|
||||
private final static String STR_TOPIC = "topic";
|
||||
private final static String STR_MSG = "msg";
|
||||
private final static String STR_SUB = "sub";
|
||||
private final static String STR_CRED = "cred";
|
||||
private final static String STR_USER = "user";
|
||||
|
||||
public String id;
|
||||
public String topic;
|
||||
public String what;
|
||||
public MsgRange[] delseq;
|
||||
public String user;
|
||||
public Credential cred;
|
||||
public Boolean hard;
|
||||
|
||||
public MsgClientDel() {}
|
||||
|
||||
private MsgClientDel(String id, String topic, String what, MsgRange[] ranges, String user, Credential cred, boolean hard) {
|
||||
this.id = id;
|
||||
this.topic = topic;
|
||||
this.what = what;
|
||||
// null value will cause the field to be skipped during serialization instead of sending 0/null/[].
|
||||
this.delseq = what.equals(STR_MSG) ? ranges : null;
|
||||
this.user = what.equals(STR_SUB) || what.equals(STR_USER) ? user : null;
|
||||
this.cred = what.equals(STR_CRED) ? cred : null;
|
||||
this.hard = hard ? true : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all messages in multiple ranges
|
||||
*/
|
||||
public MsgClientDel(String id, String topic, MsgRange[] ranges, boolean hard) {
|
||||
this(id, topic, STR_MSG, ranges, null, null, hard);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Delete all messages in one range.
|
||||
*/
|
||||
public MsgClientDel(String id, String topic, int fromId, int toId, boolean hard) {
|
||||
this(id, topic, new MsgRange[]{new MsgRange(fromId, toId)}, hard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one message.
|
||||
*/
|
||||
public MsgClientDel(String id, String topic, int seqId, boolean hard) {
|
||||
this(id, topic, STR_MSG, new MsgRange[]{new MsgRange(seqId)}, null, null, hard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete topic
|
||||
*/
|
||||
public MsgClientDel(String id, String topic) {
|
||||
this(id, topic, STR_TOPIC, null, null, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete current user.
|
||||
*/
|
||||
public MsgClientDel(String id) {
|
||||
this(id, null, STR_USER, null, null, null, false);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Delete subscription of the given user. The server will reject request if the <i>user</i> is null.
|
||||
*/
|
||||
public MsgClientDel(String id, String topic, String user) {
|
||||
this(id, topic, STR_SUB, null, user, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected credential.
|
||||
*/
|
||||
public MsgClientDel(String id, Credential cred) {
|
||||
this(id, Tinode.TOPIC_ME, STR_CRED, null, null, cred, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
// MsgClientExtra is not a stand-alone message but extra data which augments the main payload.
|
||||
public class MsgClientExtra {
|
||||
public MsgClientExtra() {
|
||||
attachments = null;
|
||||
}
|
||||
|
||||
public MsgClientExtra(String[] attachments) {
|
||||
this.attachments = attachments;
|
||||
}
|
||||
|
||||
// Array of out-of-band attachments which have to be exempted from GC.
|
||||
public final String[] attachments;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Metadata query packet
|
||||
* Id string `json:"id,omitempty"`
|
||||
* Topic string `json:"topic"`
|
||||
* What string `json:"what"`
|
||||
* Desc *MsgGetOpts `json:"desc,omitempty"`
|
||||
* Sub *MsgGetOpts `json:"sub,omitempty"`
|
||||
* Data *MsgBrowseOpts `json:"data,omitempty"`
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MsgClientGet implements Serializable {
|
||||
public String id;
|
||||
public String topic;
|
||||
public String what;
|
||||
|
||||
public MetaGetDesc desc;
|
||||
public MetaGetSub sub;
|
||||
public MetaGetData data;
|
||||
|
||||
public MsgClientGet() {}
|
||||
|
||||
public MsgClientGet(String id, String topic, MsgGetMeta query) {
|
||||
this.id = id;
|
||||
this.topic = topic;
|
||||
this.what = query.what;
|
||||
this.desc = query.desc;
|
||||
this.sub = query.sub;
|
||||
this.data = query.data;
|
||||
}
|
||||
|
||||
public MsgClientGet(String id, String topic, MetaGetDesc desc,
|
||||
MetaGetSub sub, MetaGetData data) {
|
||||
this.id = id;
|
||||
this.topic = topic;
|
||||
this.what = "";
|
||||
if (desc != null) {
|
||||
this.what = "desc";
|
||||
this.desc = desc;
|
||||
}
|
||||
if (sub != null) {
|
||||
this.what += " sub";
|
||||
this.sub = sub;
|
||||
}
|
||||
if (data != null) {
|
||||
this.what += " data";
|
||||
this.data = data;
|
||||
}
|
||||
this.what = this.what.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Create client handshake packet.
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MsgClientHi implements Serializable {
|
||||
|
||||
public final String id;
|
||||
public final String ver;
|
||||
public final String ua; // User Agent
|
||||
public final String dev; // Device ID
|
||||
public final String lang;
|
||||
public final Boolean bkg;
|
||||
|
||||
public MsgClientHi(String id, String version, String userAgent, String deviceId, String lang, Boolean background) {
|
||||
this.id = id;
|
||||
this.ver = version;
|
||||
this.ua = userAgent;
|
||||
this.dev = deviceId;
|
||||
this.lang = lang;
|
||||
this.bkg = background;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Topic unsubscribe packet.
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MsgClientLeave implements Serializable {
|
||||
public String id;
|
||||
public String topic;
|
||||
public Boolean unsub;
|
||||
|
||||
public MsgClientLeave() {
|
||||
}
|
||||
|
||||
public MsgClientLeave(String id, String topic, boolean unsub) {
|
||||
this.id = id;
|
||||
this.topic = topic;
|
||||
this.unsub = unsub ? true : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Login packet.
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MsgClientLogin implements Serializable {
|
||||
|
||||
public String id;
|
||||
public String scheme; // "basic" or "token"
|
||||
public String secret; // i.e. <uname + ":" + password> or token
|
||||
public Credential []cred;
|
||||
|
||||
public MsgClientLogin() {
|
||||
}
|
||||
|
||||
public MsgClientLogin(String id, String scheme, String secret) {
|
||||
this.id = id;
|
||||
this.scheme = scheme;
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public void Login(String scheme, String secret) {
|
||||
this.scheme = scheme;
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void addCred(Credential c) {
|
||||
if (cred == null) {
|
||||
cred = new Credential[1];
|
||||
} else {
|
||||
cred = Arrays.copyOf(cred, cred.length + 1);
|
||||
}
|
||||
cred[cred.length-1] = c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Typing and read/received notifications packet.
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MsgClientNote implements Serializable {
|
||||
public final String topic; // topic to notify, required
|
||||
public final String what; // one of "kp" (key press), "read" (read notification),
|
||||
// "rcpt" (received notification), "call" (video call event),
|
||||
// any other string will cause
|
||||
// message to be silently dropped, required
|
||||
public final Integer seq; // ID of the message being acknowledged, required for rcpt & read
|
||||
|
||||
public final String event; // Event (set only when what="call")
|
||||
public final Object payload; // Arbitrary json payload (set only when what="call")
|
||||
|
||||
public MsgClientNote(String topic, String what, int seq) {
|
||||
this(topic, what, seq, null, null);
|
||||
}
|
||||
|
||||
public MsgClientNote(String topic, String what, int seq, String event, Object payload) {
|
||||
this.topic = topic;
|
||||
this.what = what;
|
||||
this.seq = seq > 0 ? seq : null;
|
||||
this.event = event;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Publish to topic packet.
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MsgClientPub implements Serializable {
|
||||
public String id;
|
||||
public String topic;
|
||||
public Boolean noecho;
|
||||
public Map<String, Object> head;
|
||||
public Object content;
|
||||
|
||||
public MsgClientPub() {
|
||||
}
|
||||
|
||||
public MsgClientPub(String id, String topic, Boolean noecho, Object content, Map<String, Object> head) {
|
||||
this.id = id;
|
||||
this.topic = topic;
|
||||
this.noecho = noecho ? true : null;
|
||||
this.content = content;
|
||||
this.head = head;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Metadata update packet: description, subscription, tags, credentials.
|
||||
* <p>
|
||||
* topic metadata, new topic & new subscriptions only
|
||||
* Desc *MsgSetDesc `json:"desc,omitempty"`
|
||||
* <p>
|
||||
* Subscription parameters
|
||||
* Sub *MsgSetSub `json:"sub,omitempty"`
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MsgClientSet<Pu,Pr> implements Serializable {
|
||||
// Keep track of NULL assignments to fields.
|
||||
@JsonIgnore
|
||||
int nulls = 0;
|
||||
|
||||
public String id;
|
||||
public String topic;
|
||||
|
||||
public MetaSetDesc<Pu,Pr> desc;
|
||||
public MetaSetSub sub;
|
||||
public String[] tags;
|
||||
public Credential cred;
|
||||
public Map<String, Object> aux;
|
||||
|
||||
public MsgClientSet() {}
|
||||
|
||||
public MsgClientSet(String id, String topic, MsgSetMeta<Pu,Pr> meta) {
|
||||
this(id, topic, meta.desc, meta.sub, meta.tags, meta.cred, meta.aux);
|
||||
nulls = meta.nulls;
|
||||
}
|
||||
|
||||
protected MsgClientSet(String id, String topic) {
|
||||
this.id = id;
|
||||
this.topic = topic;
|
||||
}
|
||||
|
||||
protected MsgClientSet(String id, String topic, MetaSetDesc<Pu, Pr> desc,
|
||||
MetaSetSub sub, String[] tags, Credential cred,
|
||||
Map<String, Object> aux) {
|
||||
this.id = id;
|
||||
this.topic = topic;
|
||||
this.desc = desc;
|
||||
this.sub = sub;
|
||||
this.tags = tags;
|
||||
this.cred = cred;
|
||||
this.aux = aux;
|
||||
}
|
||||
|
||||
public static class Builder<Pu,Pr> {
|
||||
private final MsgClientSet<Pu,Pr> msm;
|
||||
|
||||
public Builder(String id, String topic) {
|
||||
msm = new MsgClientSet<>(id, topic);
|
||||
}
|
||||
|
||||
public void with(MetaSetDesc<Pu,Pr> desc) {
|
||||
msm.desc = desc;
|
||||
if (desc == null) {
|
||||
msm.nulls |= MsgSetMeta.NULL_DESC;
|
||||
}
|
||||
}
|
||||
|
||||
public void with(MetaSetSub sub) {
|
||||
msm.sub = sub;
|
||||
if (sub == null) {
|
||||
msm.nulls |= MsgSetMeta.NULL_SUB;
|
||||
}
|
||||
}
|
||||
|
||||
public void with(String[] tags) {
|
||||
msm.tags = tags;
|
||||
if (tags == null || tags.length == 0) {
|
||||
msm.nulls |= MsgSetMeta.NULL_TAGS;
|
||||
}
|
||||
}
|
||||
|
||||
public void with(Credential cred) {
|
||||
msm.cred = cred;
|
||||
if (cred == null) {
|
||||
msm.nulls |= MsgSetMeta.NULL_CRED;
|
||||
}
|
||||
}
|
||||
|
||||
public void with(Map<String,Object> aux) {
|
||||
msm.aux = aux;
|
||||
if (aux == null || aux.isEmpty()) {
|
||||
msm.nulls |= MsgSetMeta.NULL_AUX;
|
||||
}
|
||||
}
|
||||
|
||||
public MsgClientSet<Pu,Pr> build() {
|
||||
return msm;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import co.tinode.tinodesdk.Tinode;
|
||||
|
||||
/**
|
||||
* Custom serializer for MsgSetMeta to serialize assigned NULL fields as Tinode.NULL_VALUE string.
|
||||
*/
|
||||
public class MsgClientSetSerializer extends StdSerializer<MsgClientSet<?,?>> {
|
||||
public MsgClientSetSerializer() {
|
||||
super(MsgClientSet.class, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(MsgClientSet<?,?> value, JsonGenerator gen, SerializerProvider provider) throws IOException {
|
||||
gen.writeStartObject();
|
||||
if (value.id != null && !value.id.isEmpty()) {
|
||||
gen.writeStringField("id", value.id);
|
||||
}
|
||||
if (value.topic != null && !value.topic.isEmpty()) {
|
||||
gen.writeStringField("topic", value.topic);
|
||||
}
|
||||
|
||||
if (value.desc != null) {
|
||||
gen.writeObjectField("desc", value.desc);
|
||||
} else if ((value.nulls & MsgSetMeta.NULL_DESC) != 0) {
|
||||
gen.writeStringField("desc", Tinode.NULL_VALUE);
|
||||
}
|
||||
|
||||
if (value.sub != null) {
|
||||
gen.writeObjectField("sub", value.sub);
|
||||
} else if ((value.nulls & MsgSetMeta.NULL_SUB) != 0) {
|
||||
gen.writeStringField("sub", Tinode.NULL_VALUE);
|
||||
}
|
||||
|
||||
if (value.tags != null && value.tags.length != 0) {
|
||||
gen.writeFieldName("tags");
|
||||
gen.writeArray(value.tags, 0, value.tags.length);
|
||||
} else if ((value.nulls & MsgSetMeta.NULL_TAGS) != 0) {
|
||||
gen.writeFieldName("tags");
|
||||
gen.writeArray(new String[]{Tinode.NULL_VALUE}, 0, 1);
|
||||
}
|
||||
|
||||
if (value.cred != null) {
|
||||
gen.writeObjectField("cred", value.cred);
|
||||
} else if ((value.nulls & MsgSetMeta.NULL_CRED) != 0) {
|
||||
gen.writeStringField("cred", Tinode.NULL_VALUE);
|
||||
}
|
||||
|
||||
if (value.aux != null) {
|
||||
gen.writeObjectField("aux", value.aux);
|
||||
} else if ((value.nulls & MsgSetMeta.NULL_AUX) != 0) {
|
||||
gen.writeStringField("aux", Tinode.NULL_VALUE);
|
||||
}
|
||||
gen.writeEndObject();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Subscribe to topic packet.
|
||||
*
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MsgClientSub<Pu,Pr> implements Serializable {
|
||||
public String id;
|
||||
public String topic;
|
||||
public MsgSetMeta<Pu,Pr> set;
|
||||
public MsgGetMeta get;
|
||||
|
||||
public MsgClientSub() {}
|
||||
|
||||
public MsgClientSub(String id, String topic, MsgSetMeta<Pu,Pr> set, MsgGetMeta get) {
|
||||
this.id = id;
|
||||
this.topic = topic;
|
||||
this.set = set;
|
||||
this.get = get;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Topic metadata request.
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class MsgGetMeta implements Serializable {
|
||||
private static final int DESC_SET = 0x01;
|
||||
private static final int SUB_SET = 0x02;
|
||||
private static final int DATA_SET = 0x04;
|
||||
private static final int DEL_SET = 0x08;
|
||||
private static final int TAGS_SET = 0x10;
|
||||
private static final int CRED_SET = 0x20;
|
||||
private static final int AUX_SET = 0x40;
|
||||
private static final String DESC = "desc";
|
||||
private static final String SUB = "sub";
|
||||
private static final String DATA = "data";
|
||||
private static final String DEL = "del";
|
||||
private static final String TAGS = "tags";
|
||||
private static final String CRED = "cred";
|
||||
private static final String AUX = "aux";
|
||||
@JsonIgnore
|
||||
private int mSet = 0;
|
||||
|
||||
public String what;
|
||||
public MetaGetDesc desc;
|
||||
public MetaGetSub sub;
|
||||
public MetaGetData data;
|
||||
public MetaGetData del;
|
||||
|
||||
/**
|
||||
* Empty query.
|
||||
*/
|
||||
public MsgGetMeta() { }
|
||||
|
||||
/**
|
||||
* Generate query to get specific data:
|
||||
*
|
||||
* @param desc request topic description
|
||||
* @param sub request subscriptions
|
||||
* @param data request data messages
|
||||
*/
|
||||
public MsgGetMeta(MetaGetDesc desc, MetaGetSub sub, MetaGetData data, MetaGetData del,
|
||||
Boolean tags, Boolean cred, Boolean aux) {
|
||||
this.desc = desc;
|
||||
this.sub = sub;
|
||||
this.data = data;
|
||||
this.del = del;
|
||||
if (tags != null && tags) {
|
||||
this.mSet = TAGS_SET;
|
||||
}
|
||||
if (cred != null && cred) {
|
||||
this.mSet |= CRED_SET;
|
||||
}
|
||||
if (aux != null && aux) {
|
||||
this.mSet |= AUX_SET;
|
||||
}
|
||||
buildWhat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate query to get subscription:
|
||||
*
|
||||
* @param sub request subscriptions
|
||||
*/
|
||||
public MsgGetMeta(MetaGetSub sub) {
|
||||
this.sub = sub;
|
||||
buildWhat();
|
||||
}
|
||||
|
||||
private MsgGetMeta(String what) {
|
||||
this.what = what;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "[" + what + "]" +
|
||||
" desc=[" + (desc != null ? desc.toString() : "null") + "]," +
|
||||
" sub=[" + (sub != null? sub.toString() : "null") + "]," +
|
||||
" data=[" + (data != null ? data.toString() : "null") + "]," +
|
||||
" del=[" + (del != null ? del.toString() : "null") + "]" +
|
||||
" tags=[" + ((mSet & TAGS_SET) != 0 ? "set" : "null") + "]" +
|
||||
" cred=[" + ((mSet & CRED_SET) != 0 ? "set" : "null") + "]" +
|
||||
" aux=[" + ((mSet & AUX_SET) != 0 ? "set" : "null") + "]";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Request topic description
|
||||
*
|
||||
* @param ims timestamp to receive public if it's newer than ims; could be null
|
||||
*/
|
||||
// Do not add @JsonIgnore here.
|
||||
public void setDesc(Date ims) {
|
||||
if (ims != null) {
|
||||
desc = new MetaGetDesc();
|
||||
desc.ims = ims;
|
||||
}
|
||||
mSet |= DESC_SET;
|
||||
buildWhat();
|
||||
}
|
||||
|
||||
// Do not add @JsonIgnore here.
|
||||
public void setSub(Date ims, Integer limit) {
|
||||
if (ims != null || limit != null) {
|
||||
sub = new MetaGetSub(ims, limit);
|
||||
}
|
||||
mSet |= SUB_SET;
|
||||
buildWhat();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void setSubUser(String user, Date ims, Integer limit) {
|
||||
if (ims != null || limit != null || user != null) {
|
||||
sub = new MetaGetSub(ims, limit);
|
||||
sub.setUser(user);
|
||||
}
|
||||
mSet |= SUB_SET;
|
||||
buildWhat();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void setSubTopic(String topic, Date ims, Integer limit) {
|
||||
if (ims != null || limit != null || topic != null) {
|
||||
sub = new MetaGetSub(ims, limit);
|
||||
sub.setTopic(topic);
|
||||
}
|
||||
mSet |= SUB_SET;
|
||||
buildWhat();
|
||||
}
|
||||
|
||||
// Do not add @JsonIgnore here.
|
||||
public void setData(Integer since, Integer before, Integer limit) {
|
||||
if (since != null || before != null || limit != null) {
|
||||
data = new MetaGetData(since, before, limit);
|
||||
}
|
||||
mSet |= DATA_SET;
|
||||
buildWhat();
|
||||
}
|
||||
|
||||
public void setData(MsgRange[] ranges, Integer limit) {
|
||||
if (ranges != null || limit != null) {
|
||||
data = new MetaGetData(ranges, limit);
|
||||
}
|
||||
mSet |= DATA_SET;
|
||||
buildWhat();
|
||||
}
|
||||
|
||||
// Do not add @JsonIgnore here.
|
||||
public void setDel(Integer since, Integer limit) {
|
||||
if (since != null || limit != null) {
|
||||
del = new MetaGetData(since, null, limit);
|
||||
}
|
||||
mSet |= DEL_SET;
|
||||
buildWhat();
|
||||
}
|
||||
|
||||
// Do not add @JsonIgnore here.
|
||||
public void setTags() {
|
||||
mSet |= TAGS_SET;
|
||||
buildWhat();
|
||||
}
|
||||
|
||||
// Do not add @JsonIgnore here.
|
||||
public void setCred() {
|
||||
mSet |= CRED_SET;
|
||||
buildWhat();
|
||||
}
|
||||
|
||||
// Do not add @JsonIgnore here.
|
||||
public void setAux() {
|
||||
mSet |= AUX_SET;
|
||||
buildWhat();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
private void buildWhat() {
|
||||
List<String> parts = new LinkedList<>();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
if (desc != null || (mSet & DESC_SET) != 0) {
|
||||
parts.add(DESC);
|
||||
}
|
||||
if (sub != null || (mSet & SUB_SET) != 0) {
|
||||
parts.add(SUB);
|
||||
}
|
||||
if (data != null || (mSet & DATA_SET) != 0) {
|
||||
parts.add(DATA);
|
||||
}
|
||||
if (del != null || (mSet & DEL_SET) != 0) {
|
||||
parts.add(DEL);
|
||||
}
|
||||
if ((mSet & TAGS_SET) != 0) {
|
||||
parts.add(TAGS);
|
||||
}
|
||||
if ((mSet & CRED_SET) != 0) {
|
||||
parts.add(CRED);
|
||||
}
|
||||
if ((mSet & AUX_SET) != 0) {
|
||||
parts.add(AUX);
|
||||
}
|
||||
|
||||
if (!parts.isEmpty()) {
|
||||
sb.append(parts.get(0));
|
||||
for (int i=1; i < parts.size(); i++) {
|
||||
sb.append(" ").append(parts.get(i));
|
||||
}
|
||||
}
|
||||
what = sb.toString();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isEmpty() {
|
||||
return mSet == 0;
|
||||
}
|
||||
|
||||
public static MsgGetMeta desc() {
|
||||
return new MsgGetMeta(DESC);
|
||||
}
|
||||
|
||||
public static MsgGetMeta data() {
|
||||
return new MsgGetMeta(DATA);
|
||||
}
|
||||
|
||||
public static MsgGetMeta sub() {
|
||||
return new MsgGetMeta(SUB);
|
||||
}
|
||||
|
||||
public static MsgGetMeta del() {
|
||||
return new MsgGetMeta(DEL);
|
||||
}
|
||||
|
||||
public static MsgGetMeta tags() {
|
||||
return new MsgGetMeta(TAGS);
|
||||
}
|
||||
|
||||
public static MsgGetMeta cred() {
|
||||
return new MsgGetMeta(CRED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
/**
|
||||
* Range is either an individual ID (hi=0 || hi==null) or a range of deleted IDs, low end inclusive (closed),
|
||||
* high-end exclusive (open): [low .. hi), e.g. 1..5 → 1, 2, 3, 4
|
||||
*/
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
@SuppressWarnings("WeakerAccess")
|
||||
public class MsgRange implements Comparable<MsgRange>, Serializable {
|
||||
// The low value is required, thus it's a primitive type.
|
||||
public final int low;
|
||||
// The high value is optional.
|
||||
public Integer hi;
|
||||
|
||||
public MsgRange() {
|
||||
low = 0;
|
||||
}
|
||||
|
||||
public MsgRange(int id) {
|
||||
low = id;
|
||||
}
|
||||
|
||||
public MsgRange(int low, int hi) {
|
||||
this.low = low;
|
||||
this.hi = hi;
|
||||
}
|
||||
|
||||
public MsgRange(MsgRange that) {
|
||||
this.low = that.low;
|
||||
this.hi = that.hi;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(MsgRange other) {
|
||||
int r = low - other.low;
|
||||
if (r == 0) {
|
||||
r = nullableCompare(other.hi, hi);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
if (obj == null || getClass() != obj.getClass()) {
|
||||
return false;
|
||||
}
|
||||
MsgRange other = (MsgRange) obj;
|
||||
return low == other.low && nullableCompare(hi, other.hi) == 0;
|
||||
}
|
||||
|
||||
@SuppressWarnings("NullableProblems")
|
||||
@Override
|
||||
public String toString() {
|
||||
return "{low: " + low + (hi != null ? (", hi: " + hi) : "") + "}";
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public int getLower() {
|
||||
return low;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public int getUpper() {
|
||||
return (hi != null && hi != 0) ? hi : low + 1;
|
||||
}
|
||||
|
||||
protected boolean tryExtending(int h) {
|
||||
boolean done = false;
|
||||
if (h == low) {
|
||||
done = true;
|
||||
} else if (hi != null && hi != 0) {
|
||||
if (h == hi) {
|
||||
hi ++;
|
||||
done = true;
|
||||
}
|
||||
} else if (h == low + 1) {
|
||||
hi = h + 1;
|
||||
done = true;
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
// If <b>hi</b> is meaningless or invalid, remove it.
|
||||
protected void normalize() {
|
||||
if (hi != null && hi <= low + 1) {
|
||||
hi = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert List of IDs to multiple ranges.
|
||||
*/
|
||||
public static MsgRange[] toRanges(@Nullable final List<Integer> list) {
|
||||
if (list == null || list.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Make sure the IDs are sorted in ascending order.
|
||||
Collections.sort(list);
|
||||
|
||||
ArrayList<MsgRange> ranges = new ArrayList<>();
|
||||
MsgRange curr = new MsgRange(list.get(0));
|
||||
ranges.add(curr);
|
||||
for (int i = 1; i < list.size(); i++) {
|
||||
if (!curr.tryExtending(list.get(i))) {
|
||||
curr.normalize();
|
||||
// Start a new range
|
||||
curr = new MsgRange(list.get(i));
|
||||
ranges.add(curr);
|
||||
}
|
||||
}
|
||||
|
||||
// No need to sort the ranges. They are already sorted.
|
||||
|
||||
return ranges.toArray(new MsgRange[]{});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert array of IDs to multiple ranges.
|
||||
*/
|
||||
public static @Nullable MsgRange[] toRanges(final int[] list) {
|
||||
if (list == null || list.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Make sure the IDs are sorted in ascending order.
|
||||
Arrays.sort(list);
|
||||
|
||||
ArrayList<MsgRange> ranges = new ArrayList<>();
|
||||
MsgRange curr = new MsgRange(list[0]);
|
||||
ranges.add(curr);
|
||||
for (int i = 1; i < list.length; i++) {
|
||||
if (!curr.tryExtending(list[i])) {
|
||||
curr.normalize();
|
||||
// Start a new range
|
||||
curr = new MsgRange(list[i]);
|
||||
ranges.add(curr);
|
||||
}
|
||||
}
|
||||
|
||||
// No need to sort the ranges. They are already sorted.
|
||||
|
||||
return ranges.toArray(new MsgRange[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse multiple possibly overlapping ranges into as few ranges non-overlapping
|
||||
* ranges as possible: [1..6],[2..4],[5..7] -> [1..7].
|
||||
* The input array of ranges must be sorted.
|
||||
*
|
||||
* @param ranges ranges to collapse
|
||||
* @return non-overlapping ranges.
|
||||
*/
|
||||
public static @NotNull MsgRange[] collapse(@NotNull MsgRange[] ranges) {
|
||||
if (ranges.length < 2) {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
int prev = 0;
|
||||
for (int i = 1; i < ranges.length; i++) {
|
||||
if (ranges[prev].low == ranges[i].low) {
|
||||
// Same starting point.
|
||||
|
||||
// Earlier range is guaranteed to be wider or equal to the later range,
|
||||
// collapse two ranges into one (by doing nothing)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for full or partial overlap
|
||||
int prev_hi = ranges[prev].getUpper();
|
||||
if (prev_hi >= ranges[i].low) {
|
||||
// Partial overlap: previous hi is above or equal to current low.
|
||||
int curr_hi = ranges[i].getUpper();
|
||||
if (curr_hi > prev_hi) {
|
||||
// Current range extends further than previous, extend previous.
|
||||
ranges[prev].hi = curr_hi;
|
||||
}
|
||||
// Otherwise the next range is fully within the previous range, consume it by doing nothing.
|
||||
continue;
|
||||
}
|
||||
|
||||
// No overlap. Just copy the values.
|
||||
prev ++;
|
||||
ranges[prev] = ranges[i];
|
||||
}
|
||||
|
||||
// Clip array.
|
||||
if (prev + 1 < ranges.length) {
|
||||
ranges = Arrays.copyOfRange(ranges, 0, prev + 1);
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum enclosing range. The input array must be sorted.
|
||||
*/
|
||||
public static @Nullable MsgRange enclosing(final @NotNull MsgRange[] ranges) {
|
||||
if (ranges == null || ranges.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
MsgRange first = new MsgRange(ranges[0]);
|
||||
if (ranges.length > 1) {
|
||||
MsgRange last = ranges[ranges.length - 1];
|
||||
first.hi = last.getUpper();
|
||||
} else if (first.hi == null) {
|
||||
first.hi = first.low + 1;
|
||||
}
|
||||
|
||||
return first;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find gaps in the given array of non-overlapping ranges. The input must be sorted and overlaps removed.
|
||||
*/
|
||||
public static @NotNull MsgRange[] gaps(@NotNull MsgRange[] ranges) {
|
||||
if (ranges.length < 2) {
|
||||
return new MsgRange[0];
|
||||
}
|
||||
|
||||
List<MsgRange> gaps = new LinkedList<>();
|
||||
for (int i = 1; i < ranges.length; i++) {
|
||||
if (ranges[i-1].getUpper() < ranges[i].getLower()) {
|
||||
// Gap found
|
||||
gaps.add(new MsgRange(ranges[i-1].getUpper(), ranges[i].getLower()));
|
||||
}
|
||||
}
|
||||
|
||||
return gaps.toArray(new MsgRange[0]);
|
||||
}
|
||||
|
||||
// Comparable which does not crash on null values. Nulls are treated as 0.
|
||||
private static int nullableCompare(Integer x, Integer y) {
|
||||
return (x == null ? 0 : x) - (y == null ? 0 : y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut 'clip' range out of the 'src' range.
|
||||
*
|
||||
* @param src source range to subtract from.
|
||||
* @param clip range to subtract.
|
||||
* @return array with 0, 1 or 2 elements.
|
||||
*/
|
||||
public static @NotNull MsgRange[] clip(@NotNull MsgRange src, @NotNull MsgRange clip) {
|
||||
if (clip.getUpper() < src.getLower() || clip.getLower() >= src.getUpper()) {
|
||||
// Clip is completely outside of src, no intersection.
|
||||
return new MsgRange[]{src};
|
||||
}
|
||||
|
||||
if (clip.low <= src.low) {
|
||||
if (clip.getUpper() >= src.getUpper()) {
|
||||
// The source range is completely inside the clipping range.
|
||||
return new MsgRange[0];
|
||||
}
|
||||
// Partial clipping at the top.
|
||||
return new MsgRange[]{new MsgRange(src.getLower(), clip.getUpper())};
|
||||
}
|
||||
|
||||
// Range on the lower end.
|
||||
MsgRange lower = new MsgRange(src.getLower(), clip.getLower());
|
||||
if (clip.getUpper() < src.getUpper()) {
|
||||
return new MsgRange[]{lower, new MsgRange(clip.getUpper(), src.getUpper())};
|
||||
}
|
||||
return new MsgRange[]{lower};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Control packet
|
||||
*/
|
||||
public class MsgServerCtrl implements Serializable {
|
||||
public String id;
|
||||
public String topic;
|
||||
public int code;
|
||||
public String text;
|
||||
public Date ts;
|
||||
public Map<String, Object> params;
|
||||
|
||||
public MsgServerCtrl() {
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
private Object getParam(String key, Object def) {
|
||||
if (params == null) {
|
||||
return def;
|
||||
}
|
||||
Object result = params.get(key);
|
||||
return result != null ? result : def;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Integer getIntParam(String key, Integer def) {
|
||||
return (Integer) getParam(key, def);
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public String getStringParam(String key, String def) {
|
||||
return (String) getParam(key, def);
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Boolean getBoolParam(String key, Boolean def) {
|
||||
return (Boolean) getParam(key, def);
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@SuppressWarnings("unchecked")
|
||||
public Iterator<String> getStringIteratorParam(String key) {
|
||||
Iterable<String> it = params != null ? (Iterable<String>) params.get(key) : null;
|
||||
return it != null && it.iterator().hasNext() ? it.iterator() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Content packet
|
||||
*/
|
||||
public class MsgServerData implements Serializable {
|
||||
public enum WebRTC {ACCEPTED, BUSY, DECLINED, DISCONNECTED, FINISHED, MISSED, STARTED, UNKNOWN}
|
||||
|
||||
public String id;
|
||||
public String topic;
|
||||
public Map<String, Object> head;
|
||||
public String from;
|
||||
public Date ts;
|
||||
public int seq;
|
||||
public Drafty content;
|
||||
|
||||
public MsgServerData() {
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Object getHeader(String key) {
|
||||
return head == null ? null : head.get(key);
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public int getIntHeader(String key, int def) {
|
||||
Object val = getHeader(key);
|
||||
if (val instanceof Integer) {
|
||||
return (int) val;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public String getStringHeader(String key) {
|
||||
Object val = getHeader(key);
|
||||
if (val instanceof String) {
|
||||
return (String) val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean getBooleanHeader(String key) {
|
||||
Object val = getHeader(key);
|
||||
if (val instanceof Boolean) {
|
||||
return (Boolean) val;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static WebRTC parseWebRTC(String what) {
|
||||
if (what == null) {
|
||||
return WebRTC.UNKNOWN;
|
||||
} else if (what.equals("accepted")) {
|
||||
return WebRTC.ACCEPTED;
|
||||
} else if (what.equals("busy")) {
|
||||
return WebRTC.BUSY;
|
||||
} else if (what.equals("declined")) {
|
||||
return WebRTC.DECLINED;
|
||||
} else if (what.equals("disconnected")) {
|
||||
return WebRTC.DISCONNECTED;
|
||||
} else if (what.equals("finished")) {
|
||||
return WebRTC.FINISHED;
|
||||
} else if (what.equals("missed")) {
|
||||
return WebRTC.MISSED;
|
||||
} else if (what.equals("started")) {
|
||||
return WebRTC.STARTED;
|
||||
}
|
||||
return WebRTC.UNKNOWN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Info packet
|
||||
*/
|
||||
public class MsgServerInfo implements Serializable {
|
||||
public enum What {CALA, CALL, KP, KPA, KPV, RECV, READ, UNKNOWN}
|
||||
public enum Event {ACCEPT, ANSWER, HANG_UP, ICE_CANDIDATE, OFFER, RINGING, UNKNOWN}
|
||||
|
||||
public String topic;
|
||||
public String src;
|
||||
public String from;
|
||||
public String what;
|
||||
public Integer seq;
|
||||
// "event" and "payload" are video call event and its associated JSON payload.
|
||||
// Set only when what="call".
|
||||
public String event;
|
||||
public Object payload;
|
||||
|
||||
public MsgServerInfo() {
|
||||
}
|
||||
|
||||
public static What parseWhat(String what) {
|
||||
if (what == null) {
|
||||
return What.UNKNOWN;
|
||||
} else if (what.equals("kp")) {
|
||||
return What.KP;
|
||||
} else if (what.equals("kpa")) {
|
||||
return What.KPA;
|
||||
} else if (what.equals("kpv")) {
|
||||
return What.KPV;
|
||||
} else if (what.equals("recv")) {
|
||||
return What.RECV;
|
||||
} else if (what.equals("read")) {
|
||||
return What.READ;
|
||||
} else if (what.equals("call")) {
|
||||
return What.CALL;
|
||||
} else if (what.equals("cala")) {
|
||||
return What.CALA;
|
||||
}
|
||||
return What.UNKNOWN;
|
||||
}
|
||||
|
||||
public static Event parseEvent(String event) {
|
||||
if (event == null) {
|
||||
return Event.UNKNOWN;
|
||||
} else if (event.equals("accept")) {
|
||||
return Event.ACCEPT;
|
||||
} else if (event.equals("answer")) {
|
||||
return Event.ANSWER;
|
||||
} else if (event.equals("hang-up")) {
|
||||
return Event.HANG_UP;
|
||||
} else if (event.equals("ice-candidate")) {
|
||||
return Event.ICE_CANDIDATE;
|
||||
} else if (event.equals("offer")) {
|
||||
return Event.OFFER;
|
||||
} else if (event.equals("ringing")) {
|
||||
return Event.RINGING;
|
||||
}
|
||||
return Event.UNKNOWN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Metadata packet
|
||||
*/
|
||||
public class MsgServerMeta<DP, DR, SP, SR> implements Serializable {
|
||||
public String id;
|
||||
public String topic;
|
||||
public Date ts;
|
||||
public Description<DP,DR> desc;
|
||||
public Subscription<SP,SR>[] sub;
|
||||
public DelValues del;
|
||||
public String[] tags;
|
||||
public Credential[] cred;
|
||||
public Map<String, Object> aux;
|
||||
|
||||
public MsgServerMeta() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Presence notification.
|
||||
*/
|
||||
public class MsgServerPres implements Serializable {
|
||||
public enum What {ON, OFF, UPD, GONE, TERM, ACS, MSG, UA, RECV, READ, DEL, TAGS, AUX, UNKNOWN}
|
||||
|
||||
public String topic;
|
||||
public String src;
|
||||
public String what;
|
||||
public Integer seq;
|
||||
public Integer clear;
|
||||
public MsgRange[] delseq;
|
||||
public String ua;
|
||||
public String act;
|
||||
public String tgt;
|
||||
public AccessChange dacs;
|
||||
|
||||
public MsgServerPres() {
|
||||
}
|
||||
|
||||
public static What parseWhat(String what) {
|
||||
if (what == null) {
|
||||
return What.UNKNOWN;
|
||||
} else if (what.equals("on")) {
|
||||
return What.ON;
|
||||
} else if (what.equals("off")) {
|
||||
return What.OFF;
|
||||
} else if (what.equals("upd")) {
|
||||
return What.UPD;
|
||||
} else if (what.equals("acs")) {
|
||||
return What.ACS;
|
||||
} else if (what.equals("gone")) {
|
||||
return What.GONE;
|
||||
} else if (what.equals("term")) {
|
||||
return What.TERM;
|
||||
} else if (what.equals("msg")) {
|
||||
return What.MSG;
|
||||
} else if (what.equals("ua")) {
|
||||
return What.UA;
|
||||
} else if (what.equals("recv")) {
|
||||
return What.RECV;
|
||||
} else if (what.equals("read")) {
|
||||
return What.READ;
|
||||
} else if (what.equals("del")) {
|
||||
return What.DEL;
|
||||
} else if (what.equals("tags")) {
|
||||
return What.TAGS;
|
||||
} else if (what.equals("aux")) {
|
||||
return What.AUX;
|
||||
}
|
||||
return What.UNKNOWN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Payload for setting meta params, a combination of MetaSetDesc, MetaSetSub, tags, credential.
|
||||
* <p>
|
||||
* Must use custom serializer to handle assigned NULL values, which should be converted to Tinode.NULL_VALUE.
|
||||
*/
|
||||
public class MsgSetMeta<Pu,Pr> implements Serializable {
|
||||
static final int NULL_DESC = 0x1;
|
||||
static final int NULL_SUB = 0x2;
|
||||
static final int NULL_TAGS = 0x4;
|
||||
static final int NULL_CRED = 0x8;
|
||||
static final int NULL_AUX = 0x10;
|
||||
// Keep track of NULL assignments to fields.
|
||||
@JsonIgnore
|
||||
int nulls = 0;
|
||||
|
||||
public MetaSetDesc<Pu,Pr> desc = null;
|
||||
public MetaSetSub sub = null;
|
||||
public String[] tags = null;
|
||||
public Credential cred = null;
|
||||
public Map<String,Object> aux = null;
|
||||
|
||||
public MsgSetMeta() {}
|
||||
|
||||
public boolean isDescSet() {
|
||||
return desc != null || (nulls & NULL_DESC) != 0;
|
||||
}
|
||||
|
||||
public boolean isSubSet() {
|
||||
return sub != null || (nulls & NULL_SUB) != 0;
|
||||
}
|
||||
|
||||
public boolean isTagsSet() {
|
||||
return tags != null || (nulls & NULL_TAGS) != 0;
|
||||
|
||||
}
|
||||
public boolean isAuxSet() {
|
||||
return aux != null || (nulls & NULL_AUX) != 0;
|
||||
}
|
||||
public boolean isCredSet() {
|
||||
return cred != null || (nulls & NULL_CRED) != 0;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return desc == null &&
|
||||
sub == null &&
|
||||
tags == null &&
|
||||
cred == null &&
|
||||
aux == null &&
|
||||
(nulls & (NULL_DESC | NULL_SUB | NULL_TAGS | NULL_CRED | NULL_AUX)) == 0;
|
||||
}
|
||||
|
||||
public static class Builder<Pu,Pr> {
|
||||
private final MsgSetMeta<Pu,Pr> msm;
|
||||
|
||||
public Builder() {
|
||||
msm = new MsgSetMeta<>();
|
||||
}
|
||||
|
||||
public Builder<Pu,Pr> with(MetaSetDesc<Pu,Pr> desc) {
|
||||
msm.desc = desc;
|
||||
if (desc == null) {
|
||||
msm.nulls |= NULL_DESC;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder<Pu,Pr> with(MetaSetSub sub) {
|
||||
msm.sub = sub;
|
||||
if (sub == null) {
|
||||
msm.nulls |= NULL_SUB;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder<Pu,Pr> with(String[] tags) {
|
||||
msm.tags = tags;
|
||||
if (tags == null || tags.length == 0) {
|
||||
msm.nulls |= NULL_TAGS;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder<Pu,Pr> with(Credential cred) {
|
||||
msm.cred = cred;
|
||||
if (cred == null) {
|
||||
msm.nulls |= NULL_CRED;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder<Pu,Pr> with(Map<String,Object> aux) {
|
||||
msm.aux = aux;
|
||||
if (aux == null || aux.isEmpty()) {
|
||||
msm.nulls |= NULL_AUX;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public MsgSetMeta<Pu,Pr> build() {
|
||||
return msm;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return msm.isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import co.tinode.tinodesdk.Tinode;
|
||||
|
||||
/**
|
||||
* Custom serializer for MsgSetMeta to serialize assigned NULL fields as Tinode.NULL_VALUE string.
|
||||
*/
|
||||
public class MsgSetMetaSerializer extends StdSerializer<MsgSetMeta<?,?>> {
|
||||
public MsgSetMetaSerializer() {
|
||||
super(MsgSetMeta.class, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(MsgSetMeta<?,?> value, JsonGenerator gen, SerializerProvider provider) throws IOException {
|
||||
gen.writeStartObject();
|
||||
if (value.desc != null) {
|
||||
gen.writeObjectField("desc", value.desc);
|
||||
} else if ((value.nulls & MsgSetMeta.NULL_DESC) != 0) {
|
||||
gen.writeStringField("desc", Tinode.NULL_VALUE);
|
||||
}
|
||||
|
||||
if (value.sub != null) {
|
||||
gen.writeObjectField("sub", value.sub);
|
||||
} else if ((value.nulls & MsgSetMeta.NULL_SUB) != 0) {
|
||||
gen.writeStringField("sub", Tinode.NULL_VALUE);
|
||||
}
|
||||
|
||||
if (value.tags != null && value.tags.length != 0) {
|
||||
gen.writeFieldName("tags");
|
||||
gen.writeArray(value.tags, 0, value.tags.length);
|
||||
} else if ((value.nulls & MsgSetMeta.NULL_TAGS) != 0) {
|
||||
gen.writeFieldName("tags");
|
||||
gen.writeArray(new String[]{Tinode.NULL_VALUE}, 0, 1);
|
||||
}
|
||||
|
||||
if (value.cred != null) {
|
||||
gen.writeObjectField("cred", value.cred);
|
||||
} else if ((value.nulls & MsgSetMeta.NULL_CRED) != 0) {
|
||||
gen.writeStringField("cred", Tinode.NULL_VALUE);
|
||||
}
|
||||
|
||||
if (value.aux != null && !value.aux.isEmpty()) {
|
||||
gen.writeObjectField("aux", value.aux);
|
||||
} else if ((value.nulls & MsgSetMeta.NULL_AUX) != 0) {
|
||||
gen.writeStringField("aux", Tinode.NULL_VALUE);
|
||||
}
|
||||
|
||||
gen.writeEndObject();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
public class Pair<F, S> {
|
||||
public final F first;
|
||||
public S second;
|
||||
|
||||
public Pair(F f, S s) {
|
||||
first = f;
|
||||
second = s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import co.tinode.tinodesdk.Tinode;
|
||||
|
||||
/**
|
||||
* Common type of the `private` field of {meta}: holds structured
|
||||
* data, such as comment and archival status.
|
||||
*/
|
||||
public class PrivateType extends HashMap<String,Object> implements Mergeable, Serializable {
|
||||
public PrivateType() {
|
||||
super();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
private Object getValue(String name) {
|
||||
Object value = get(name);
|
||||
if (Tinode.isNull(value)) {
|
||||
value = null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public String getComment() {
|
||||
try {
|
||||
return (String) getValue("comment");
|
||||
} catch (ClassCastException ignored) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void setComment(String comment) {
|
||||
put("comment", comment != null && !comment.isEmpty() ? comment : Tinode.NULL_VALUE);
|
||||
}
|
||||
|
||||
public Boolean isArchived() {
|
||||
try {
|
||||
return (Boolean) getValue("arch");
|
||||
} catch (ClassCastException ignored) {}
|
||||
return Boolean.FALSE;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void setArchived(boolean arch) {
|
||||
put("arch", arch ? true : Tinode.NULL_VALUE);
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public int getPinnedRank(@NotNull String topicName) {
|
||||
try {
|
||||
List<String> tpins = getPinnedTopics();
|
||||
if (tpins == null || tpins.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int idx = tpins.indexOf(topicName);
|
||||
if (idx >= 0) {
|
||||
return tpins.size() - idx;
|
||||
}
|
||||
} catch (ClassCastException ignored) {}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of pinned topics.
|
||||
*
|
||||
* @return List of pinned topic names including empty list,
|
||||
* or null if pinned topics are not defined.
|
||||
*/
|
||||
@JsonIgnore
|
||||
public List<String> getPinnedTopics() {
|
||||
Object value = get("tpin");
|
||||
if (value == null) {
|
||||
// Not set, return null.
|
||||
return null;
|
||||
}
|
||||
if (Tinode.isNull(value)) {
|
||||
// Explicitly set to empty, return empty list.
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<String> tpins = null;
|
||||
try {
|
||||
tpins = ((List<?>) value).stream()
|
||||
.map(obj -> obj instanceof String ? (String) obj : null)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
} catch (ClassCastException ignored) {
|
||||
// The list is malformed, return null.
|
||||
}
|
||||
return tpins;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void setPinnedTopics(@NotNull List<String> tpins) {
|
||||
if (tpins.isEmpty()) {
|
||||
put("tpin", Tinode.NULL_VALUE);
|
||||
return;
|
||||
}
|
||||
put("tpin", tpins);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean merge(Mergeable another) {
|
||||
if (!(another instanceof PrivateType apt)) {
|
||||
return false;
|
||||
}
|
||||
this.putAll(apt);
|
||||
return apt.size() > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Combined server message
|
||||
*/
|
||||
public class ServerMessage<DP, DR, SP, SR> implements Serializable {
|
||||
public static final int STATUS_CONTINUE = 100; // RFC 7231, 6.2.1
|
||||
public static final int STATUS_SWITCHING_PROTOCOLS = 101; // RFC 7231, 6.2.2
|
||||
public static final int STATUS_PROCESSING = 102; // RFC 2518, 10.1
|
||||
|
||||
public static final int STATUS_OK = 200; // RFC 7231, 6.3.1
|
||||
public static final int STATUS_CREATED = 201; // RFC 7231, 6.3.2
|
||||
public static final int STATUS_ACCEPTED = 202; // RFC 7231, 6.3.3
|
||||
public static final int STATUS_NON_AUTHORITATIVE_INFO = 203; // RFC 7231, 6.3.4
|
||||
public static final int STATUS_NO_CONTENT = 204; // RFC 7231, 6.3.5
|
||||
public static final int STATUS_RESET_CONTENT = 205; // RFC 7231, 6.3.6
|
||||
public static final int STATUS_PARTIAL_CONTENT = 206; // RFC 7233, 4.1
|
||||
public static final int STATUS_MULTI_STATUS = 207; // RFC 4918, 11.1
|
||||
public static final int STATUS_ALREADY_REPORTED = 208; // RFC 5842, 7.1
|
||||
|
||||
public static final int STATUS_MULTIPLE_CHOICES = 300; // RFC 7231, 6.4.1
|
||||
public static final int STATUS_MOVED_PERMANENTLY = 301; // RFC 7231, 6.4.2
|
||||
public static final int STATUS_FOUND = 302; // RFC 7231, 6.4.3
|
||||
public static final int STATUS_SEE_OTHER = 303; // RFC 7231, 6.4.4
|
||||
public static final int STATUS_NOT_MODIFIED = 304; // RFC 7232, 4.1
|
||||
public static final int STATUS_USE_PROXY = 305; // RFC 7231, 6.4.5
|
||||
|
||||
public static final int STATUS_BAD_REQUEST = 400; // RFC 7231, 6.5.1
|
||||
public static final int STATUS_UNAUTHORIZED = 401; // RFC 7235, 3.1
|
||||
public static final int STATUS_FORBIDDEN = 403; // RFC 7231, 6.5.3
|
||||
public static final int STATUS_NOT_FOUND = 404; // RFC 7231, 6.5.4
|
||||
public static final int STATUS_METHOD_NOT_ALLOWED = 405; // RFC 7231, 6.5.5
|
||||
public static final int STATUS_NOT_ACCEPTABLE = 406; // RFC 7231, 6.5.6
|
||||
public static final int STATUS_REQUEST_TIMEOUT = 408; // RFC 7231, 6.5.7
|
||||
public static final int STATUS_CONFLICT = 409; // RFC 7231, 6.5.8
|
||||
public static final int STATUS_GONE = 410; // RFC 7231, 6.5.9
|
||||
|
||||
public static final int STATUS_INTERNAL_SERVER_ERROR = 500; // RFC 7231, 6.6.1
|
||||
public static final int STATUS_NOT_IMPLEMENTED = 501; // RFC 7231, 6.6.2
|
||||
public static final int STATUS_BAD_GATEWAY = 502; // RFC 7231, 6.6.3
|
||||
public static final int STATUS_SERVICE_UNAVAILABLE = 503; // RFC 7231, 6.6.4
|
||||
public static final int STATUS_GATEWAY_TIMEOUT = 504; // RFC 7231, 6.6.5
|
||||
public static final int STATUS_HTTP_VERSION_NOT_SUPPORTED = 505; // RFC 7231, 6.6.6
|
||||
|
||||
public MsgServerData data;
|
||||
public MsgServerMeta<DP,DR,SP,SR> meta;
|
||||
public MsgServerCtrl ctrl;
|
||||
public MsgServerPres pres;
|
||||
public MsgServerInfo info;
|
||||
|
||||
public ServerMessage() {}
|
||||
public ServerMessage(MsgServerData data) {
|
||||
this.data = data;
|
||||
}
|
||||
public ServerMessage(MsgServerMeta<DP,DR,SP,SR> meta) {
|
||||
this.meta = meta;
|
||||
}
|
||||
public ServerMessage(MsgServerCtrl ctrl) {
|
||||
this.ctrl = ctrl;
|
||||
}
|
||||
public ServerMessage(MsgServerPres pres) {
|
||||
this.pres = pres;
|
||||
}
|
||||
public ServerMessage(MsgServerInfo info) {
|
||||
this.info = info;
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
int count = 0;
|
||||
if (data != null) count ++;
|
||||
if (meta != null) count ++;
|
||||
if (ctrl != null) count ++;
|
||||
if (pres != null) count ++;
|
||||
if (info != null) count ++;
|
||||
|
||||
return count == 1;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
import co.tinode.tinodesdk.LocalData;
|
||||
|
||||
/**
|
||||
* Subscription to topic.
|
||||
*/
|
||||
public class Subscription<SP,SR> implements LocalData, Serializable {
|
||||
public String user;
|
||||
public Date updated;
|
||||
public Date deleted;
|
||||
public Date touched;
|
||||
|
||||
public Acs acs;
|
||||
public int read;
|
||||
public int recv;
|
||||
@JsonProperty("private")
|
||||
public SR priv;
|
||||
public Boolean online;
|
||||
|
||||
public String topic;
|
||||
public int seq;
|
||||
public int clear;
|
||||
public int subcnt;
|
||||
|
||||
@JsonProperty("public")
|
||||
public SP pub;
|
||||
public TrustedType trusted;
|
||||
|
||||
public LastSeen seen;
|
||||
|
||||
// Local values
|
||||
@JsonIgnore
|
||||
private Payload mLocal;
|
||||
|
||||
public Subscription() {
|
||||
}
|
||||
|
||||
public Subscription(Subscription<SP,SR> sub) {
|
||||
this.merge(sub);
|
||||
mLocal = null;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public String getUnique() {
|
||||
if (topic == null) {
|
||||
return user;
|
||||
}
|
||||
if (user == null) {
|
||||
return topic;
|
||||
}
|
||||
return topic + ":" + user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two subscriptions.
|
||||
*/
|
||||
public boolean merge(Subscription<SP,SR> sub) {
|
||||
boolean changed = false;
|
||||
|
||||
if (user == null && sub.user != null && !sub.user.isEmpty()) {
|
||||
user = sub.user;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if ((sub.updated != null) && (updated == null || updated.before(sub.updated))) {
|
||||
updated = sub.updated;
|
||||
|
||||
if (sub.pub != null) {
|
||||
pub = sub.pub;
|
||||
}
|
||||
if (sub.trusted != null) {
|
||||
if (trusted == null) {
|
||||
trusted = new TrustedType();
|
||||
}
|
||||
trusted.merge(sub.trusted);
|
||||
}
|
||||
changed = true;
|
||||
} else {
|
||||
if (pub == null && sub.pub != null) {
|
||||
pub = sub.pub;
|
||||
changed = true;
|
||||
}
|
||||
if (trusted == null && sub.trusted != null) {
|
||||
trusted = sub.trusted;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ((sub.touched != null) && (touched == null || touched.before(sub.touched))) {
|
||||
touched = sub.touched;
|
||||
}
|
||||
|
||||
if (sub.deleted != null) {
|
||||
deleted = sub.deleted;
|
||||
}
|
||||
|
||||
if (sub.acs != null) {
|
||||
if (acs == null) {
|
||||
acs = new Acs(sub.acs);
|
||||
changed = true;
|
||||
} else {
|
||||
changed = acs.merge(sub.acs) || changed;
|
||||
}
|
||||
}
|
||||
|
||||
if (sub.read > read) {
|
||||
read = sub.read;
|
||||
changed = true;
|
||||
}
|
||||
if (sub.recv > recv) {
|
||||
recv = sub.recv;
|
||||
changed = true;
|
||||
}
|
||||
if (sub.clear > clear) {
|
||||
clear = sub.clear;
|
||||
changed = true;
|
||||
}
|
||||
if (sub.subcnt > 0) {
|
||||
subcnt = sub.subcnt;
|
||||
changed = true;
|
||||
}
|
||||
if (sub.priv != null) {
|
||||
priv = sub.priv;
|
||||
}
|
||||
|
||||
if (sub.online != null) {
|
||||
online = sub.online;
|
||||
}
|
||||
|
||||
if ((topic == null || topic.isEmpty()) && sub.topic != null && !sub.topic.isEmpty()) {
|
||||
topic = sub.topic;
|
||||
changed = true;
|
||||
}
|
||||
if (sub.seq > seq) {
|
||||
seq = sub.seq;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (sub.seen != null) {
|
||||
if (seen == null) {
|
||||
seen = sub.seen;
|
||||
changed = true;
|
||||
} else {
|
||||
changed = seen.merge(sub.seen) || changed;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge changes from {meta set} packet with the subscription.
|
||||
*/
|
||||
public boolean merge(MetaSetSub sub) {
|
||||
boolean changed = false;
|
||||
|
||||
if (sub.mode != null && acs == null) {
|
||||
acs = new Acs();
|
||||
}
|
||||
|
||||
if (sub.user != null && !sub.user.isEmpty()) {
|
||||
if (user == null) {
|
||||
user = sub.user;
|
||||
changed = true;
|
||||
}
|
||||
if (sub.mode != null) {
|
||||
acs.setGiven(sub.mode);
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
if (sub.mode != null) {
|
||||
acs.setWant(sub.mode);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
public void updateAccessMode(AccessChange ac) {
|
||||
if (acs == null) {
|
||||
acs = new Acs();
|
||||
}
|
||||
acs.update(ac);
|
||||
}
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public void setLocal(Payload value) {
|
||||
mLocal = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public Payload getLocal() {
|
||||
return mLocal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Arrays;
|
||||
|
||||
import co.tinode.tinodesdk.Tinode;
|
||||
|
||||
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_DEFAULT;
|
||||
|
||||
@JsonInclude(NON_DEFAULT)
|
||||
public class TheCard implements Serializable, Mergeable {
|
||||
|
||||
public final static String TYPE_HOME = "HOME";
|
||||
public final static String TYPE_WORK = "WORK";
|
||||
public final static String TYPE_MOBILE = "MOBILE";
|
||||
public final static String TYPE_PERSONAL = "PERSONAL";
|
||||
public final static String TYPE_BUSINESS = "BUSINESS";
|
||||
public final static String TYPE_OTHER = "OTHER";
|
||||
// Full name
|
||||
public String fn;
|
||||
public Name n;
|
||||
public Organization org;
|
||||
// List of phone numbers associated with the contact.
|
||||
public Contact[] tel;
|
||||
// List of contact's email addresses.
|
||||
public Contact[] email;
|
||||
// All other communication options.
|
||||
public Contact[] comm;
|
||||
// Avatar photo. Pure java does not have a useful bitmap class, so keeping it as bits here.
|
||||
public Photo photo;
|
||||
// Birthday.
|
||||
public Birthday bday;
|
||||
// Free-form description.
|
||||
public String note;
|
||||
|
||||
public TheCard() {
|
||||
}
|
||||
|
||||
public TheCard(String fullName, byte[] avatarBits, String avatarImageType) {
|
||||
this.fn = fullName;
|
||||
if (avatarBits != null) {
|
||||
this.photo = new Photo(avatarBits, avatarImageType);
|
||||
}
|
||||
}
|
||||
|
||||
public TheCard(String fullName, String avatarRef, String avatarImageType) {
|
||||
this.fn = fullName;
|
||||
if (avatarRef != null) {
|
||||
this.photo = new Photo(avatarRef, avatarImageType);
|
||||
}
|
||||
}
|
||||
|
||||
private static String typeToString(ContactType tp) {
|
||||
if (tp == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (tp) {
|
||||
case HOME -> TYPE_HOME;
|
||||
case WORK -> TYPE_WORK;
|
||||
case MOBILE -> TYPE_MOBILE;
|
||||
case PERSONAL -> TYPE_PERSONAL;
|
||||
case BUSINESS -> TYPE_BUSINESS;
|
||||
default -> TYPE_OTHER;
|
||||
};
|
||||
}
|
||||
|
||||
private static ContactType stringToType(String str) {
|
||||
if (str == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (str) {
|
||||
case TYPE_HOME -> ContactType.HOME;
|
||||
case TYPE_WORK -> ContactType.WORK;
|
||||
case TYPE_MOBILE -> ContactType.MOBILE;
|
||||
case TYPE_PERSONAL -> ContactType.PERSONAL;
|
||||
case TYPE_BUSINESS -> ContactType.BUSINESS;
|
||||
default -> ContactType.OTHER;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean merge(@NotNull Field[] fields, @NotNull Mergeable dst, Mergeable src) {
|
||||
if (src == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean updated = false;
|
||||
try {
|
||||
for (Field f : fields) {
|
||||
Object sf = f.get(src);
|
||||
Object df = f.get(dst);
|
||||
// TODO: handle Collection / Array types.
|
||||
// Source is provided.
|
||||
if (df == null || sf == null) {
|
||||
// Either source or destination is null, replace.
|
||||
f.set(dst, sf);
|
||||
updated = sf == df || updated;
|
||||
} else if (df instanceof Mergeable) {
|
||||
// Complex mergeable types, use merge().
|
||||
updated = ((Mergeable) df).merge((Mergeable) sf) || updated;
|
||||
} else if (!df.equals(sf)) {
|
||||
if (sf instanceof String) {
|
||||
// String, check for Tinode NULL.
|
||||
f.set(dst, !Tinode.isNull(sf) ? sf : null);
|
||||
} else {
|
||||
// All other non-mergeable types: replace.
|
||||
f.set(dst, sf);
|
||||
}
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
} catch (IllegalAccessException ignored) {
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public byte[] getPhotoBits() {
|
||||
return photo == null ? null : photo.data;
|
||||
}
|
||||
@JsonIgnore
|
||||
public boolean isPhotoRef() {
|
||||
return photo != null && photo.ref != null;
|
||||
}
|
||||
@JsonIgnore
|
||||
public String getPhotoRef() {
|
||||
return photo != null ? photo.ref : null;
|
||||
}
|
||||
@JsonIgnore
|
||||
public String[] getPhotoRefs() {
|
||||
if (isPhotoRef()) {
|
||||
return new String[] { photo.ref };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@JsonIgnore
|
||||
public String getPhotoMimeType() {
|
||||
return photo == null ? null : ("image/" + photo.type);
|
||||
}
|
||||
|
||||
public void addPhone(String phone, String type) {
|
||||
tel = Contact.append(tel, new Contact(type, phone));
|
||||
}
|
||||
|
||||
public void addEmail(String addr, String type) {
|
||||
email = Contact.append(email, new Contact(type, addr));
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public String getPhoneByType(String type) {
|
||||
String phone = null;
|
||||
if (tel != null) {
|
||||
for (Contact tt : tel) {
|
||||
if (tt.type != null && tt.type.equals(type)) {
|
||||
phone = tt.uri;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public String getPhoneByType(ContactType type) {
|
||||
return getPhoneByType(typeToString(type));
|
||||
}
|
||||
|
||||
public enum ContactType {HOME, WORK, MOBILE, PERSONAL, BUSINESS, OTHER}
|
||||
|
||||
public static <T extends TheCard> T copy(T dst, TheCard src) {
|
||||
dst.fn = src.fn;
|
||||
dst.n = src.n != null ? src.n.copy() : null;
|
||||
dst.org = src.org != null ? src.org.copy() : null;
|
||||
dst.tel = Contact.copyArray(src.tel);
|
||||
dst.email = Contact.copyArray(src.email);
|
||||
dst.comm = Contact.copyArray(src.comm);
|
||||
// Shallow copy of the photo
|
||||
dst.photo = src.photo != null ? src.photo.copy() : null;
|
||||
|
||||
return dst;
|
||||
}
|
||||
|
||||
public TheCard copy() {
|
||||
return copy(new TheCard(), this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean merge(Mergeable another) {
|
||||
if (!(another instanceof TheCard)) {
|
||||
return false;
|
||||
}
|
||||
return merge(this.getClass().getFields(), this, another);
|
||||
}
|
||||
|
||||
public static class Name implements Serializable, Mergeable {
|
||||
public String surname;
|
||||
public String given;
|
||||
public String additional;
|
||||
public String prefix;
|
||||
public String suffix;
|
||||
|
||||
public Name copy() {
|
||||
Name dst = new Name();
|
||||
dst.surname = surname;
|
||||
dst.given = given;
|
||||
dst.additional = additional;
|
||||
dst.prefix = prefix;
|
||||
dst.suffix = suffix;
|
||||
return dst;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean merge(Mergeable another) {
|
||||
if (!(another instanceof Name that)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
surname = that.surname;
|
||||
given = that.given;
|
||||
additional = that.additional;
|
||||
prefix = that.prefix;
|
||||
suffix = that.suffix;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Organization implements Serializable, Mergeable {
|
||||
public String fn;
|
||||
public String title;
|
||||
|
||||
public Organization copy() {
|
||||
Organization dst = new Organization();
|
||||
dst.fn = fn;
|
||||
dst.title = title;
|
||||
return dst;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean merge(Mergeable another) {
|
||||
if (!(another instanceof Organization that)) {
|
||||
return false;
|
||||
}
|
||||
fn = that.fn;
|
||||
title = that.title;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Contact implements Serializable, Comparable<Contact>, Mergeable {
|
||||
public String type;
|
||||
public String name;
|
||||
public String uri;
|
||||
|
||||
private ContactType tp;
|
||||
|
||||
public Contact(String type, String uri) {
|
||||
this(type, null, uri);
|
||||
}
|
||||
|
||||
public Contact(String type, String name, String uri) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.uri = uri;
|
||||
this.tp = stringToType(type);
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public ContactType getType() {
|
||||
if (tp != null) {
|
||||
return tp;
|
||||
}
|
||||
return stringToType(type);
|
||||
}
|
||||
|
||||
public Contact copy() {
|
||||
return new Contact(type, name, uri);
|
||||
}
|
||||
|
||||
static Contact[] copyArray(Contact[] src) {
|
||||
Contact[] dst = null;
|
||||
if (src != null) {
|
||||
dst = Arrays.copyOf(src, src.length);
|
||||
for (int i=0; i<src.length;i++) {
|
||||
dst[i] = src[i].copy();
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
public static Contact[] append(Contact[] arr, Contact val) {
|
||||
int insertAt;
|
||||
if (arr == null) {
|
||||
arr = new Contact[1];
|
||||
arr[0] = val;
|
||||
} else if ((insertAt = Arrays.binarySearch(arr, val)) >=0) {
|
||||
if (!TYPE_OTHER.equals(val.type)) {
|
||||
arr[insertAt].type = val.type;
|
||||
arr[insertAt].tp = stringToType(val.type);
|
||||
}
|
||||
} else {
|
||||
arr = Arrays.copyOf(arr, arr.length + 1);
|
||||
arr[arr.length - 1] = val;
|
||||
}
|
||||
|
||||
Arrays.sort(arr);
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(Contact c) {
|
||||
return uri.compareTo(c.uri);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return type + ":" + uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean merge(Mergeable another) {
|
||||
if (!(another instanceof Contact that)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
type = that.type;
|
||||
name = that.name;
|
||||
uri = that.uri;
|
||||
tp = stringToType(type);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic container for image data.
|
||||
*/
|
||||
public static class Photo implements Serializable, Mergeable {
|
||||
// Image bits (preview or full image).
|
||||
public byte[] data;
|
||||
// Second component of image mime type, i.e. 'png' for 'image/png'.
|
||||
public String type;
|
||||
// URL of the image.
|
||||
public String ref;
|
||||
// Intended dimensions of the full image
|
||||
public Integer width, height;
|
||||
// Size of the full image in bytes.
|
||||
public Integer size;
|
||||
|
||||
public Photo() {}
|
||||
|
||||
/**
|
||||
* The main constructor.
|
||||
*
|
||||
* @param bits binary image data
|
||||
* @param type the specific part of image/ mime type, i.e. 'jpeg' or 'png'.
|
||||
*/
|
||||
public Photo(byte[] bits, String type) {
|
||||
this.data = bits;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* The main constructor.
|
||||
*
|
||||
* @param ref Uri of the image.
|
||||
* @param type the specific part of image/ mime type, i.e. 'jpeg' or 'png'.
|
||||
*/
|
||||
public Photo(String ref, String type) {
|
||||
this.ref = ref;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a copy of a photo instance.
|
||||
* @return new instance of Photo.
|
||||
*/
|
||||
public Photo copy() {
|
||||
Photo ret = new Photo();
|
||||
ret.data = data;
|
||||
ret.type = type;
|
||||
ret.ref = ref;
|
||||
ret.width = width;
|
||||
ret.height = height;
|
||||
ret.size = size;
|
||||
return ret;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean merge(Mergeable another) {
|
||||
if (!(another instanceof Photo that)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Direct copy. No need to check for nulls.
|
||||
data = that.data;
|
||||
type = that.type;
|
||||
ref = that.ref;
|
||||
width = that.width;
|
||||
height = that.height;
|
||||
size = that.size;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Birthday implements Serializable, Mergeable {
|
||||
// Year like 1975
|
||||
Integer y;
|
||||
// Month 1..12.
|
||||
Integer m;
|
||||
// Day 1..31.
|
||||
Integer d;
|
||||
|
||||
public Birthday() {}
|
||||
|
||||
public Birthday copy() {
|
||||
Birthday ret = new Birthday();
|
||||
ret.y = y;
|
||||
ret.m = m;
|
||||
ret.d = d;
|
||||
return ret;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean merge(Mergeable another) {
|
||||
if (!(another instanceof Birthday that)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
y = that.y;
|
||||
m = that.m;
|
||||
d = that.d;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
|
||||
import co.tinode.tinodesdk.Tinode;
|
||||
|
||||
/**
|
||||
* Common type of the `private` field of {meta}: holds structured
|
||||
* data, such as comment and archival status.
|
||||
*/
|
||||
public class TrustedType extends HashMap<String,Object> implements Mergeable, Serializable {
|
||||
public TrustedType() {
|
||||
super();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Boolean getBooleanValue(String name) {
|
||||
Object val = get(name);
|
||||
if (Tinode.isNull(val)) {
|
||||
return false;
|
||||
}
|
||||
if (val instanceof Boolean) {
|
||||
return (boolean) val;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean merge(Mergeable another) {
|
||||
if (!(another instanceof TrustedType apt)) {
|
||||
return false;
|
||||
}
|
||||
this.putAll(apt);
|
||||
return apt.size() > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
package co.tinode.tinodesdk;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import co.tinode.tinodesdk.model.Pair;
|
||||
|
||||
/**
|
||||
* Unit tests for static methods in Tinode class.
|
||||
*/
|
||||
public class TinodeTest {
|
||||
|
||||
/**
|
||||
* Test getTypeFactory() returns non-null value
|
||||
*/
|
||||
@Test
|
||||
public void testGetTypeFactory() {
|
||||
assertNotNull(Tinode.getTypeFactory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test getJsonMapper() returns non-null value
|
||||
*/
|
||||
@Test
|
||||
public void testGetJsonMapper() {
|
||||
assertNotNull(Tinode.getJsonMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isNull() with NULL_VALUE string
|
||||
*/
|
||||
@Test
|
||||
public void testIsNull_WithNullValue() {
|
||||
assertTrue(Tinode.isNull(Tinode.NULL_VALUE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isNull() with regular string
|
||||
*/
|
||||
@Test
|
||||
public void testIsNull_WithRegularString() {
|
||||
assertFalse(Tinode.isNull("regular_string"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isNull() with other object types
|
||||
*/
|
||||
@Test
|
||||
public void testIsNull_WithOtherTypes() {
|
||||
assertFalse(Tinode.isNull(123));
|
||||
assertFalse(Tinode.isNull(45.67));
|
||||
assertFalse(Tinode.isNull(new Object()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test tagSplit() with valid tag
|
||||
*/
|
||||
@Test
|
||||
public void testTagSplit_ValidTag() {
|
||||
Pair<String, String> result = Tinode.tagSplit("email:user@example.com");
|
||||
assertNotNull(result);
|
||||
assertEquals("email", result.first);
|
||||
assertEquals("user@example.com", result.second);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test tagSplit() with tag containing colon in value
|
||||
*/
|
||||
@Test
|
||||
public void testTagSplit_TagWithColonInValue() {
|
||||
Pair<String, String> result = Tinode.tagSplit("protocol:http://example.com");
|
||||
assertNotNull(result);
|
||||
assertEquals("protocol", result.first);
|
||||
assertEquals("http://example.com", result.second);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test tagSplit() with tag with spaces
|
||||
*/
|
||||
@Test
|
||||
public void testTagSplit_TagWithSpaces() {
|
||||
Pair<String, String> result = Tinode.tagSplit(" email:user@example.com ");
|
||||
assertNotNull(result);
|
||||
assertEquals("email", result.first);
|
||||
assertEquals("user@example.com", result.second);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test tagSplit() with invalid tag (no colon)
|
||||
*/
|
||||
@Test
|
||||
public void testTagSplit_InvalidTag_NoColon() {
|
||||
Pair<String, String> result = Tinode.tagSplit("notag");
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test tagSplit() with invalid tag (empty value)
|
||||
*/
|
||||
@Test
|
||||
public void testTagSplit_InvalidTag_EmptyValue() {
|
||||
Pair<String, String> result = Tinode.tagSplit("email:");
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test setUniqueTag() adding tag to null array
|
||||
*/
|
||||
@Test
|
||||
public void testSetUniqueTag_AddToNullArray() {
|
||||
String[] result = Tinode.setUniqueTag(null, "email:test@example.com");
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.length);
|
||||
assertEquals("email:test@example.com", result[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test setUniqueTag() adding tag to empty array
|
||||
*/
|
||||
@Test
|
||||
public void testSetUniqueTag_AddToEmptyArray() {
|
||||
String[] result = Tinode.setUniqueTag(new String[]{}, "email:test@example.com");
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.length);
|
||||
assertEquals("email:test@example.com", result[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test setUniqueTag() replacing existing tag with same prefix
|
||||
*/
|
||||
@Test
|
||||
public void testSetUniqueTag_ReplaceExisting() {
|
||||
String[] tags = {"email:old@example.com", "phone:1234567890"};
|
||||
String[] result = Tinode.setUniqueTag(tags, "email:new@example.com");
|
||||
assertNotNull(result);
|
||||
assertEquals(2, result.length);
|
||||
assertTrue(contains(result, "email:new@example.com"));
|
||||
assertTrue(contains(result, "phone:1234567890"));
|
||||
assertFalse(contains(result, "email:old@example.com"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test setUniqueTag() with invalid tag
|
||||
*/
|
||||
@Test
|
||||
public void testSetUniqueTag_InvalidTag() {
|
||||
String[] result = Tinode.setUniqueTag(new String[]{"email:test@example.com"}, "invalid");
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test clearTagPrefix() removing tags with prefix
|
||||
*/
|
||||
@Test
|
||||
public void testClearTagPrefix_RemovePrefix() {
|
||||
String[] tags = {"email:test@example.com", "phone:1234567890", "email:another@example.com"};
|
||||
String[] result = Tinode.clearTagPrefix(tags, "email:");
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.length);
|
||||
assertEquals("phone:1234567890", result[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test clearTagPrefix() with no matching prefix
|
||||
*/
|
||||
@Test
|
||||
public void testClearTagPrefix_NoMatchingPrefix() {
|
||||
String[] tags = {"email:test@example.com", "phone:1234567890"};
|
||||
String[] result = Tinode.clearTagPrefix(tags, "fax:");
|
||||
assertNotNull(result);
|
||||
assertEquals(2, result.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test clearTagPrefix() with empty array
|
||||
*/
|
||||
@Test
|
||||
public void testClearTagPrefix_EmptyArray() {
|
||||
String[] result = Tinode.clearTagPrefix(new String[]{}, "email:");
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isValidTagValueFormat() with valid tag
|
||||
*/
|
||||
@Test
|
||||
public void testIsValidTagValueFormat_ValidTag() {
|
||||
assertTrue(Tinode.isValidTagValueFormat("john_doe"));
|
||||
assertTrue(Tinode.isValidTagValueFormat("user123"));
|
||||
assertTrue(Tinode.isValidTagValueFormat("valid-tag"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isValidTagValueFormat() with empty string (valid by spec)
|
||||
*/
|
||||
@Test
|
||||
public void testIsValidTagValueFormat_EmptyString() {
|
||||
assertTrue(Tinode.isValidTagValueFormat(""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isValidTagValueFormat() with null (valid by spec)
|
||||
*/
|
||||
@Test
|
||||
public void testIsValidTagValueFormat_Null() {
|
||||
assertTrue(Tinode.isValidTagValueFormat(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isValidTagValueFormat() with invalid characters
|
||||
*/
|
||||
@Test
|
||||
public void testIsValidTagValueFormat_InvalidCharacters() {
|
||||
assertFalse(Tinode.isValidTagValueFormat("user@example.com"));
|
||||
assertFalse(Tinode.isValidTagValueFormat("tag with spaces"));
|
||||
assertFalse(Tinode.isValidTagValueFormat("tag*special"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test tagByPrefix() finding tag with prefix
|
||||
*/
|
||||
@Test
|
||||
public void testTagByPrefix_FindExisting() {
|
||||
String[] tags = {"email:test@example.com", "phone:1234567890"};
|
||||
String result = Tinode.tagByPrefix(tags, "email:");
|
||||
assertEquals("email:test@example.com", result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test tagByPrefix() with no matching prefix
|
||||
*/
|
||||
@Test
|
||||
public void testTagByPrefix_NoMatch() {
|
||||
String[] tags = {"email:test@example.com", "phone:1234567890"};
|
||||
String result = Tinode.tagByPrefix(tags, "fax:");
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test tagByPrefix() finding first matching tag
|
||||
*/
|
||||
@Test
|
||||
public void testTagByPrefix_FirstMatch() {
|
||||
String[] tags = {"email:first@example.com", "email:second@example.com", "phone:1234567890"};
|
||||
String result = Tinode.tagByPrefix(tags, "email:");
|
||||
assertEquals("email:first@example.com", result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test jsonSerialize() with simple object
|
||||
*/
|
||||
@Test
|
||||
public void testJsonSerialize_SimpleObject() throws JsonProcessingException {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("key", "value");
|
||||
map.put("number", 123);
|
||||
|
||||
String json = Tinode.jsonSerialize(map);
|
||||
assertNotNull(json);
|
||||
assertTrue(json.contains("key"));
|
||||
assertTrue(json.contains("value"));
|
||||
assertTrue(json.contains("number"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test jsonSerialize() with null object
|
||||
*/
|
||||
@Test
|
||||
public void testJsonSerialize_NullObject() throws JsonProcessingException {
|
||||
String result = Tinode.jsonSerialize(null);
|
||||
assertNotNull(result);
|
||||
assertEquals("null", result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test jsonSerialize() with nested object
|
||||
*/
|
||||
@Test
|
||||
public void testJsonSerialize_NestedObject() throws JsonProcessingException {
|
||||
Map<String, Object> inner = new HashMap<>();
|
||||
inner.put("nested", "value");
|
||||
|
||||
Map<String, Object> outer = new HashMap<>();
|
||||
outer.put("outer", inner);
|
||||
|
||||
String json = Tinode.jsonSerialize(outer);
|
||||
assertNotNull(json);
|
||||
assertTrue(json.contains("nested"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test newTopic() creates MeTopic for "me"
|
||||
*/
|
||||
@Test
|
||||
public void testNewTopic_MeTopic() {
|
||||
Topic topic = Tinode.newTopic(null, Tinode.TOPIC_ME, null);
|
||||
assertNotNull(topic);
|
||||
assertIsInstance(topic, MeTopic.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test newTopic() creates FndTopic for "fnd"
|
||||
*/
|
||||
@Test
|
||||
public void testNewTopic_FndTopic() {
|
||||
Topic topic = Tinode.newTopic(null, Tinode.TOPIC_FND, null);
|
||||
assertNotNull(topic);
|
||||
assertIsInstance(topic, FndTopic.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test newTopic() creates ComTopic for other names
|
||||
*/
|
||||
@Test
|
||||
public void testNewTopic_ComTopic() {
|
||||
Topic topic = Tinode.newTopic(null, "grpABC", null);
|
||||
assertNotNull(topic);
|
||||
assertIsInstance(topic, ComTopic.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test headersForReply() creates correct map
|
||||
*/
|
||||
@Test
|
||||
public void testHeadersForReply() {
|
||||
Map<String, Object> headers = Tinode.headersForReply(42);
|
||||
assertNotNull(headers);
|
||||
assertTrue(headers.containsKey("reply"));
|
||||
assertEquals("42", headers.get("reply"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test headersForReply() with different sequence number
|
||||
*/
|
||||
@Test
|
||||
public void testHeadersForReply_DifferentSeq() {
|
||||
Map<String, Object> headers = Tinode.headersForReply(999);
|
||||
assertEquals("999", headers.get("reply"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test headersForReplacement() creates correct map
|
||||
*/
|
||||
@Test
|
||||
public void testHeadersForReplacement() {
|
||||
Map<String, Object> headers = Tinode.headersForReplacement(42);
|
||||
assertNotNull(headers);
|
||||
assertTrue(headers.containsKey("replace"));
|
||||
assertEquals(":42", headers.get("replace"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test headersForReplacement() with different sequence number
|
||||
*/
|
||||
@Test
|
||||
public void testHeadersForReplacement_DifferentSeq() {
|
||||
Map<String, Object> headers = Tinode.headersForReplacement(999);
|
||||
assertEquals(":999", headers.get("replace"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isUrlRelative() with absolute HTTP URL
|
||||
*/
|
||||
@Test
|
||||
public void testIsUrlRelative_AbsoluteHttp() {
|
||||
assertFalse(Tinode.isUrlRelative("http://example.com/path"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isUrlRelative() with absolute HTTPS URL
|
||||
*/
|
||||
@Test
|
||||
public void testIsUrlRelative_AbsoluteHttps() {
|
||||
assertFalse(Tinode.isUrlRelative("https://example.com/path"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isUrlRelative() with protocol-relative URL
|
||||
*/
|
||||
@Test
|
||||
public void testIsUrlRelative_ProtocolRelative() {
|
||||
assertFalse(Tinode.isUrlRelative("//example.com/path"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isUrlRelative() with relative path
|
||||
*/
|
||||
@Test
|
||||
public void testIsUrlRelative_RelativePath() {
|
||||
assertTrue(Tinode.isUrlRelative("/path/to/resource"));
|
||||
assertTrue(Tinode.isUrlRelative("path/to/resource"));
|
||||
assertTrue(Tinode.isUrlRelative("resource.txt"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isUrlRelative() with URL starting with whitespace
|
||||
*/
|
||||
@Test
|
||||
public void testIsUrlRelative_WithWhitespace() {
|
||||
assertFalse(Tinode.isUrlRelative(" http://example.com/path"));
|
||||
assertTrue(Tinode.isUrlRelative(" /path/to/resource"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test isUrlRelative() with weird schema part.
|
||||
*/
|
||||
@Test
|
||||
public void testIsUrlRelative_WeirdSchemas() {
|
||||
// Relative URLs
|
||||
assertTrue(Tinode.isUrlRelative("-schema://example.com/path"));
|
||||
assertTrue(Tinode.isUrlRelative("sche=ma://example.com/path"));
|
||||
assertTrue(Tinode.isUrlRelative("\"sche\\ma://example.com/path"));
|
||||
assertTrue(Tinode.isUrlRelative(":schema://example.com/path"));
|
||||
assertTrue(Tinode.isUrlRelative("s$chema://example.com/path"));
|
||||
assertTrue(Tinode.isUrlRelative("123schema://example.com/path"));
|
||||
assertTrue(Tinode.isUrlRelative("s'chema://example.com/path"));
|
||||
|
||||
// Absolute URLs
|
||||
assertFalse(Tinode.isUrlRelative("sch:ema://example.com/path"));
|
||||
assertFalse(Tinode.isUrlRelative("sch--ema://example.com/path"));
|
||||
assertFalse(Tinode.isUrlRelative("sc123ema://example.com/path"));
|
||||
assertFalse(Tinode.isUrlRelative("aaa:::://example.com/path"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parseTinodeUrl() with null input
|
||||
*/
|
||||
@Test
|
||||
public void testParseTinodeUrl_NullInput() {
|
||||
String result = Tinode.parseTinodeUrl(null);
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parseTinodeUrl() with non-tinode URL
|
||||
*/
|
||||
@Test
|
||||
public void testParseTinodeUrl_NonTinodeUrl() {
|
||||
String url = "https://example.com";
|
||||
String result = Tinode.parseTinodeUrl(url);
|
||||
assertEquals(url, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parseTinodeUrl() with valid tinode URL
|
||||
*/
|
||||
@Test
|
||||
public void testParseTinodeUrl_ValidTinodeUrl() {
|
||||
String result = Tinode.parseTinodeUrl("tinode:///id/usrABC12345");
|
||||
assertEquals("usrABC12345", result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parseTinodeUrl() with tinode URL with host
|
||||
*/
|
||||
@Test
|
||||
public void testParseTinodeUrl_TinodeUrlWithHost() {
|
||||
String result = Tinode.parseTinodeUrl("tinode://example.com/id/usrXYZ98765");
|
||||
assertEquals("usrXYZ98765", result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parseTinodeUrl() with invalid tinode URL (missing id)
|
||||
*/
|
||||
@Test
|
||||
public void testParseTinodeUrl_InvalidTinodeUrl() {
|
||||
String url = "tinode:///user/usrABC12345";
|
||||
String result = Tinode.parseTinodeUrl(url);
|
||||
assertEquals(url, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parseTinodeUrl() with tinode URL with single part
|
||||
*/
|
||||
@Test
|
||||
public void testParseTinodeUrl_TinodeUrlSinglePart() {
|
||||
String url = "tinode://onlypart";
|
||||
String result = Tinode.parseTinodeUrl(url);
|
||||
assertEquals(url, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parseTinodeUrl() with tinode URL with no host part
|
||||
*/
|
||||
@Test
|
||||
public void testParseTinodeUrl_TinodeUrlNoHost() {
|
||||
String url = "tinode:id/usrABC12345";
|
||||
String result = Tinode.parseTinodeUrl(url);
|
||||
assertEquals("usrABC12345", result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parseTinodeUrl() with tinode URL with no host and no id
|
||||
*/
|
||||
@Test
|
||||
public void testParseTinodeUrl_TinodeUrlNoHostNoId() {
|
||||
String url = "tinode:678/usrABC12345";
|
||||
String result = Tinode.parseTinodeUrl(url);
|
||||
assertEquals(url, result);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
/**
|
||||
* Check if array contains string
|
||||
*/
|
||||
private boolean contains(String[] array, String value) {
|
||||
for (String s : array) {
|
||||
if (value.equals(s)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that object is instance of class
|
||||
*/
|
||||
private void assertIsInstance(Object obj, Class<?> clazz) {
|
||||
assertTrue("Expected " + clazz.getName() + " but got " + obj.getClass().getName(),
|
||||
clazz.isInstance(obj));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class DraftyTest {
|
||||
@Test
|
||||
public void testParse() {
|
||||
// Basic formatting 1
|
||||
Drafty actual = Drafty.parse("this is *bold*, `code` and _italic_, ~strike~");
|
||||
Drafty expected = new Drafty("this is bold, code and italic, strike");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("ST", 8, 4),
|
||||
new Drafty.Style("CO", 14, 4),
|
||||
new Drafty.Style("EM", 23, 6),
|
||||
new Drafty.Style("DL", 31, 6)
|
||||
};
|
||||
assertEquals("Parse 1 has failed", expected, actual);
|
||||
|
||||
// Basic formatting over Unicode string 2.
|
||||
actual = Drafty.parse("Это *жЫрный*, `код` и _наклонный_, ~зачеркнутый~");
|
||||
expected = new Drafty("Это жЫрный, код и наклонный, зачеркнутый");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("ST", 4, 6),
|
||||
new Drafty.Style("CO", 12, 3),
|
||||
new Drafty.Style("EM", 18, 9),
|
||||
new Drafty.Style("DL", 29, 11),
|
||||
};
|
||||
assertEquals("Parse 2 has failed", expected, actual);
|
||||
|
||||
// Nested formats, string 3
|
||||
actual = Drafty.parse("combined *bold and _italic_*");
|
||||
expected = new Drafty("combined bold and italic");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("EM", 18, 6),
|
||||
new Drafty.Style("ST", 9, 15)
|
||||
};
|
||||
assertEquals("Parse 3 has failed", expected, actual);
|
||||
|
||||
// URL, string 4
|
||||
actual = Drafty.parse("an url: https://www.example.com/abc#fragment and another _www.tinode.co_");
|
||||
expected = new Drafty("an url: https://www.example.com/abc#fragment and another www.tinode.co");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("EM", 57, 13),
|
||||
new Drafty.Style(8, 36, 0),
|
||||
new Drafty.Style(57, 13, 1)
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("LN")
|
||||
.putData("url", "https://www.example.com/abc#fragment"),
|
||||
new Drafty.Entity("LN")
|
||||
.putData("url", "http://www.tinode.co")
|
||||
};
|
||||
assertEquals("Parse 4 has failed", expected, actual);
|
||||
|
||||
// Mention, string 5
|
||||
actual = Drafty.parse("this is a @mention and a #hashtag in a string");
|
||||
expected = new Drafty("this is a @mention and a #hashtag in a string");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(10, 8, 0),
|
||||
new Drafty.Style(25, 8, 1),
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("MN").putData("val", "@mention"),
|
||||
new Drafty.Entity("HT").putData("val", "#hashtag"),
|
||||
};
|
||||
assertEquals("Parse 5 has failed", expected, actual);
|
||||
|
||||
// String 6: Unicode UTF16
|
||||
actual = Drafty.parse("second #юникод");
|
||||
expected = new Drafty("second #юникод");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(7, 7, 0),
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("HT").putData("val", "#юникод"),
|
||||
};
|
||||
assertEquals("Parse 6 has failed", expected, actual);
|
||||
|
||||
// String 7: Unicode emoji UTF32. 👩🏽✈ is a medium-dark-skinned female pilot, 4 code points:
|
||||
// 👩🏽✈ == 👩 female + 🏽 fitzpatrick skin tone + ZWJ + ✈ airplane.
|
||||
actual = Drafty.parse("😀 *b1👩🏽✈️b2* smile");
|
||||
expected = new Drafty("😀 b1👩🏽✈️b2 smile");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("ST", 2, 5),
|
||||
};
|
||||
assertEquals("Parse 7 - Unicode UTF32 emoji failed", expected, actual);
|
||||
|
||||
// String 8: two lines with emoji in the first and style in the second.
|
||||
actual = Drafty.parse("first 😀 line\nsecond *line*");
|
||||
expected = new Drafty("first 😀 line second line");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("BR", 12, 1),
|
||||
new Drafty.Style("ST", 20, 4),
|
||||
};
|
||||
assertEquals("Parse 8 - Two lines with emoji failed", expected, actual);
|
||||
|
||||
// String 9: markup after emoji.
|
||||
actual = Drafty.parse("🕯️ *bold* https://google.com");
|
||||
expected = new Drafty("🕯️ bold https://google.com");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("ST", 2, 4),
|
||||
new Drafty.Style(7, 18, 0),
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("LN").putData("url", "https://google.com"),
|
||||
};
|
||||
assertEquals("Parse 9 - Markup after emoji failed", expected, actual);
|
||||
|
||||
// String 10: emoji with line breaks.
|
||||
/* 🔴Hello🔴
|
||||
🟠Hello🟠
|
||||
🟡Hello🟡 */
|
||||
actual = Drafty.parse("\uD83D\uDD34Hello\uD83D\uDD34\n" +
|
||||
"\uD83D\uDFE0Hello\uD83D\uDFE0\n" +
|
||||
"\uD83D\uDFE1Hello\uD83D\uDFE1");
|
||||
expected = new Drafty("\uD83D\uDD34Hello\uD83D\uDD34 " +
|
||||
"\uD83D\uDFE0Hello\uD83D\uDFE0 " +
|
||||
"\uD83D\uDFE1Hello\uD83D\uDFE1");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("BR", 7, 1),
|
||||
new Drafty.Style("BR", 15, 1),
|
||||
};
|
||||
assertEquals("Parse 10 - Emoji with line breaks failed", expected, actual);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testShorten() {
|
||||
final int limit = 15;
|
||||
|
||||
// ------- Shorten 1
|
||||
Drafty src = new Drafty("This is a plain text string.");
|
||||
Drafty actual = src.shorten(limit, true);
|
||||
Drafty expected = new Drafty("This is a plai…");
|
||||
assertEquals("Shorten 1 has failed", expected, actual);
|
||||
|
||||
// ------- Shorten 2
|
||||
src = new Drafty();
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(-1, 0, 0),
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("EX")
|
||||
.putData("mime", "image/jpeg")
|
||||
.putData("name", "hello.jpg")
|
||||
.putData("val", "<38992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>")
|
||||
.putData("width", 100)
|
||||
.putData("height", 80),
|
||||
};
|
||||
actual = src.shorten(limit, true);
|
||||
expected = new Drafty();
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(-1, 0, 0),
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("EX")
|
||||
.putData("mime", "image/jpeg")
|
||||
.putData("name", "hello.jpg")
|
||||
.putData("width", 100)
|
||||
.putData("height", 80),
|
||||
};
|
||||
assertEquals("Shorten 2 has failed", expected, actual);
|
||||
|
||||
// ------- Shorten 3
|
||||
src = new Drafty("https://api.tinode.co/");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 22, 0),
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("LN").putData("url", "https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
|
||||
};
|
||||
actual = src.shorten(limit, true);
|
||||
expected = new Drafty("https://api.ti…");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 15, 0),
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("LN").putData("url", "https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
|
||||
};
|
||||
assertEquals("Shorten 3 has failed", expected, actual);
|
||||
|
||||
// ------- Shorten 4 (two references to the same entity).
|
||||
src = new Drafty("Url one, two");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(9, 3, 0),
|
||||
new Drafty.Style(4, 3, 0),
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("LN").putData("url", "http://tinode.co"),
|
||||
};
|
||||
actual = src.shorten(limit, true);
|
||||
expected = new Drafty("Url one, two");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(4, 3, 0),
|
||||
new Drafty.Style(9, 3, 0),
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("LN").putData("url", "http://tinode.co"),
|
||||
};
|
||||
assertEquals("Shorten 4 has failed", expected, actual);
|
||||
|
||||
// ------- Shorten 5 (two different entities).
|
||||
src = new Drafty("Url one, two");
|
||||
src.fmt = new Drafty.Style[] {
|
||||
new Drafty.Style(4, 3, 0),
|
||||
new Drafty.Style(9, 3, 1),
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("LN").putData("url", "http://tinode.co"),
|
||||
new Drafty.Entity("LN").putData("url", "http://example.com"),
|
||||
};
|
||||
actual = src.shorten(limit, true);
|
||||
expected = new Drafty("Url one, two");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(4, 3, 0),
|
||||
new Drafty.Style(9, 3, 1),
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("LN").putData("url", "http://tinode.co"),
|
||||
new Drafty.Entity("LN").putData("url", "http://example.com"),
|
||||
};
|
||||
assertEquals("Shorten 5 has failed", expected, actual);
|
||||
|
||||
// ------- Shorten 6 (inline image)
|
||||
src = new Drafty(" ");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 1, 0),
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("IM")
|
||||
.putData("height", 213)
|
||||
.putData("width", 638)
|
||||
.putData("name", "roses.jpg")
|
||||
.putData("val", "<38992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>")
|
||||
.putData("mime", "image/jpeg"),
|
||||
};
|
||||
actual = src.shorten(limit, true);
|
||||
expected = new Drafty(" ");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 1, 0),
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("IM")
|
||||
.putData("height", 213)
|
||||
.putData("width", 638)
|
||||
.putData("name", "roses.jpg")
|
||||
.putData("mime", "image/jpeg"),
|
||||
};
|
||||
assertEquals("Shorten 6 has failed", expected, actual);
|
||||
|
||||
// ------- Shorten 7 (staggered formats)
|
||||
src = new Drafty("This text has staggered formats");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("EM", 5, 8),
|
||||
new Drafty.Style("ST", 10, 13),
|
||||
};
|
||||
actual = src.shorten(limit, true);
|
||||
expected = new Drafty("This text has …");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("EM", 5, 8),
|
||||
};
|
||||
assertEquals("Shorten 7 has failed", expected, actual);
|
||||
|
||||
// ------- Shorten 8 (multiple formatting)
|
||||
src = new Drafty("This text is formatted and deleted too");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("ST", 5, 4),
|
||||
new Drafty.Style("EM", 13, 9),
|
||||
new Drafty.Style("ST", 35, 3),
|
||||
new Drafty.Style("DL", 27, 11),
|
||||
};
|
||||
|
||||
actual = src.shorten(limit, true);
|
||||
expected = new Drafty("This text is f…");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("ST", 5, 4),
|
||||
new Drafty.Style("EM", 13, 2),
|
||||
};
|
||||
assertEquals("Shorten 8 has failed", expected, actual);
|
||||
|
||||
// ------- Shorten 9 (multibyte unicode)
|
||||
src = new Drafty("мультибайтовый юникод");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("ST", 0, 14),
|
||||
new Drafty.Style("EM", 15, 6),
|
||||
};
|
||||
actual = src.shorten(limit, true);
|
||||
expected = new Drafty("мультибайтовый…");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("ST", 0, 14),
|
||||
};
|
||||
assertEquals("Shorten 9 has failed", expected, actual);
|
||||
|
||||
// ------- Shorten 10 (quoted reply)
|
||||
src = new Drafty("Alice Johnson This is a test");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("BR", 13,1),
|
||||
new Drafty.Style(15,1, 0),
|
||||
new Drafty.Style(0, 13, 1),
|
||||
new Drafty.Style("QQ", 0, 16),
|
||||
new Drafty.Style("BR", 16,1),
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("IM")
|
||||
.putData("mime","image/jpeg")
|
||||
.putData("val", "<1292, bytes: /9j/4AAQSkZJ.123456789012345678901234567890123456789012345678901234567890.rehH5o6D/9k=>")
|
||||
.putData("width", 25)
|
||||
.putData("height", 14)
|
||||
.putData("size", 968),
|
||||
new Drafty.Entity("MN")
|
||||
.putData("val", "usr123abcDE")
|
||||
};
|
||||
actual = src.shorten(limit, true);
|
||||
|
||||
expected = new Drafty("Alice Johnson …");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 13, 0),
|
||||
new Drafty.Style("BR", 13, 1),
|
||||
new Drafty.Style("QQ", 0, 15)
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("MN")
|
||||
.putData("val", "usr123abcDE")
|
||||
};
|
||||
assertEquals("Shorten 10 has failed", expected, actual);
|
||||
|
||||
// Emoji 1
|
||||
src = Drafty.fromPlainText("a😀c😀d😀e😀f");
|
||||
actual = src.shorten(5, false);
|
||||
expected = Drafty.fromPlainText("a😀c😀…");
|
||||
assertEquals("Shorten Emoji 1 has failed", expected, actual);
|
||||
|
||||
// Emoji 2. 👩🏽✈ is a medium-dark-skinned female pilot, 4 code points:
|
||||
// '👩🏽✈' == 👩 female + 🏽 fitzpatrick skin tone + ZWJ + ✈ airplane.
|
||||
// AndroidStudio shows '👩🏽✈️' instead of '👩🏽✈' below. Ignore it.
|
||||
src = Drafty.fromPlainText("😀 b1👩🏽✈️b2 smile");
|
||||
actual = src.shorten(6, false);
|
||||
expected = Drafty.fromPlainText("😀 b1👩🏽✈️…");
|
||||
assertEquals("Shorten Emoji 2 has failed", expected, actual);
|
||||
|
||||
// String 10: compound emoji.
|
||||
/* 🔴Hello🔴 🟠Hello🟠 */
|
||||
src = Drafty.parse("\uD83D\uDD34Hello\uD83D\uDD34 " +
|
||||
"\uD83D\uDFE0Hello\uD83D\uDFE0");
|
||||
actual = src.shorten(14, false);
|
||||
expected = new Drafty("\uD83D\uDD34Hello\uD83D\uDD34 " +
|
||||
"\uD83D\uDFE0Hell…");
|
||||
assertEquals("Shorten Emoji 3 - Compound emoji has failed", expected, actual);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testForward() {
|
||||
// ------- Forward 1 (unchanged).
|
||||
Drafty src = new Drafty("Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply.");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 13, 0),
|
||||
new Drafty.Style("BR", 13,1),
|
||||
new Drafty.Style("QQ", 0, 38)
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("MN")
|
||||
.putData("val", "usr123abcDE")
|
||||
};
|
||||
Drafty actual = src.forwardedContent();
|
||||
|
||||
Drafty expected = new Drafty("Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply.");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 13, 0),
|
||||
new Drafty.Style("BR", 13,1),
|
||||
new Drafty.Style("QQ", 0, 38)
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("MN")
|
||||
.putData("val", "usr123abcDE")
|
||||
};
|
||||
assertEquals("Forward 1 has failed", expected, actual);
|
||||
|
||||
// ------- Forward 2 (mention stripped).
|
||||
src = new Drafty("➦ Alice Johnson Alice Johnson This is a simple replyThis is a reply to reply");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 15, 0),
|
||||
new Drafty.Style("BR", 15,1),
|
||||
new Drafty.Style(16, 13, 1),
|
||||
new Drafty.Style("BR", 29,1),
|
||||
new Drafty.Style("QQ", 16, 36)
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("MN").putData("val", "usr123abcDE"),
|
||||
new Drafty.Entity("MN").putData("val", "usr123abcDE")
|
||||
};
|
||||
actual = src.forwardedContent();
|
||||
expected = new Drafty("Alice Johnson This is a simple replyThis is a reply to reply");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 13, 0),
|
||||
new Drafty.Style("BR", 13,1),
|
||||
new Drafty.Style("QQ", 0, 36)
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("MN").putData("val", "usr123abcDE")
|
||||
};
|
||||
assertEquals("Forward 2 has failed", expected, actual);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPreview() {
|
||||
// ------- Preview 1.
|
||||
Drafty src = new Drafty("Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply.");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 13, 0),
|
||||
new Drafty.Style("BR", 13,1),
|
||||
new Drafty.Style("QQ", 0, 38)
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("MN")
|
||||
.putData("val", "usr123abcDE")
|
||||
};
|
||||
Drafty actual = src.preview(25);
|
||||
Drafty expected = new Drafty(" This is a Reply -> Forw…");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("QQ", 0, 1)
|
||||
};
|
||||
assertEquals("Preview 1 has failed", expected, actual);
|
||||
|
||||
// ------- Preview 2.
|
||||
src = new Drafty("➦ Alice Johnson Alice Johnson This is a simple replyThis is a reply to reply");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 15, 0),
|
||||
new Drafty.Style("BR", 15,1),
|
||||
new Drafty.Style(16, 13, 1),
|
||||
new Drafty.Style("BR", 29,1),
|
||||
new Drafty.Style("QQ", 16, 36)
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("MN").putData("val", "usr123abcDE"),
|
||||
new Drafty.Entity("MN").putData("val", "usr123abcDE")
|
||||
};
|
||||
actual = src.preview(25);
|
||||
expected = new Drafty("➦ This is a reply to re…");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 1, 0),
|
||||
new Drafty.Style("QQ", 2, 1)
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("MN")
|
||||
.putData("val", "usr123abcDE")
|
||||
};
|
||||
assertEquals("Preview 2 has failed", expected, actual);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReply() {
|
||||
// --------- Reply 1
|
||||
Drafty src = new Drafty("Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply.");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 13, 0),
|
||||
new Drafty.Style("BR", 13,1),
|
||||
new Drafty.Style("QQ", 0, 38)
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("MN")
|
||||
.putData("val", "usr123abcDE")
|
||||
};
|
||||
Drafty actual = src.replyContent(25, 3);
|
||||
Drafty expected = new Drafty("This is a Reply -> Forwa…");
|
||||
assertEquals("Reply 1 has failed", expected, actual);
|
||||
|
||||
// ----------- Reply 2
|
||||
src = new Drafty("➦ Alice Johnson Alice Johnson This is a simple replyThis is a reply to reply");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 15, 0),
|
||||
new Drafty.Style("BR", 15,1),
|
||||
new Drafty.Style(16, 13, 1),
|
||||
new Drafty.Style("BR", 29,1),
|
||||
new Drafty.Style("QQ", 16, 36)
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("MN").putData("val", "usr123abcDE"),
|
||||
new Drafty.Entity("MN").putData("val", "usr123abcDE")
|
||||
};
|
||||
actual = src.replyContent(25, 3);
|
||||
expected = new Drafty("➦ This is a reply to rep…");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("MN", 0, 1)
|
||||
};
|
||||
assertEquals("Reply 2 has failed", expected, actual);
|
||||
|
||||
// ----------- Reply 3
|
||||
src = new Drafty("Message with attachment");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(-1, 0, 0),
|
||||
new Drafty.Style("ST", 8,4)
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("EX")
|
||||
.putData("mime","image/jpeg")
|
||||
.putData("val", "<1292, bytes: /9j/4AAQSkZJ.123456789012345678901234567890123456789012345678901234567890.rehH5o6D/9k=>")
|
||||
.putData("width", 25)
|
||||
.putData("height", 14)
|
||||
.putData("size", 968)
|
||||
.putData("name", "hello.jpg"),
|
||||
};
|
||||
actual = src.replyContent(25, 3);
|
||||
expected = new Drafty("Message with attachment ");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("ST", 8,4),
|
||||
new Drafty.Style(23, 1, 0)
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("EX")
|
||||
.putData("mime","image/jpeg")
|
||||
.putData("width", 25)
|
||||
.putData("height", 14)
|
||||
.putData("size", 968)
|
||||
.putData("name", "hello.jpg"),
|
||||
};
|
||||
assertEquals("Reply 3 has failed", expected, actual);
|
||||
|
||||
// ----------- Reply 4
|
||||
src = new Drafty();
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(-1, 0, 0)
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("EX")
|
||||
.putData("mime","image/jpeg")
|
||||
.putData("val", "<1292, bytes: /9j/4AAQSkZJ.123456789012345678901234567890123456789012345678901234567890.rehH5o6D/9k=>")
|
||||
.putData("width", 25)
|
||||
.putData("height", 14)
|
||||
.putData("size", 968)
|
||||
.putData("name", "hello.jpg"),
|
||||
};
|
||||
actual = src.replyContent(25, 3);
|
||||
expected = new Drafty(" ");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 1, 0)
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("EX")
|
||||
.putData("mime","image/jpeg")
|
||||
.putData("width", 25)
|
||||
.putData("height", 14)
|
||||
.putData("size", 968)
|
||||
.putData("name", "hello.jpg"),
|
||||
};
|
||||
assertEquals("Reply 4 has failed", expected, actual);
|
||||
|
||||
// ------- Reply 5 (inline image with in-band bits only)
|
||||
src = new Drafty(" ");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 1, 0),
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("IM")
|
||||
.putData("height", 213)
|
||||
.putData("width", 638)
|
||||
.putData("name", "roses.jpg")
|
||||
.putData("val", "<38992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>")
|
||||
.putData("mime", "image/jpeg"),
|
||||
};
|
||||
actual = src.replyContent(25, 3);
|
||||
expected = new Drafty(" ");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 1, 0),
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("IM")
|
||||
.putData("height", 213)
|
||||
.putData("width", 638)
|
||||
.putData("name", "roses.jpg")
|
||||
.putData("val", "<38992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>")
|
||||
.putData("mime", "image/jpeg"),
|
||||
};
|
||||
assertEquals("Reply 5 has failed", expected, actual);
|
||||
|
||||
// ------- Reply 6 (inline image with in-band preview and out of band reference)
|
||||
src = new Drafty(" ");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 1, 0),
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("IM")
|
||||
.putData("height", 213)
|
||||
.putData("width", 638)
|
||||
.putData("name", "roses.jpg")
|
||||
.putData("val", "<3992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>")
|
||||
.putData("ref", "/v0/file/s/77A4SDFXfzY.jpe")
|
||||
.putData("mime", "image/jpeg"),
|
||||
};
|
||||
actual = src.replyContent(25, 3);
|
||||
expected = new Drafty(" ");
|
||||
expected.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 1, 0),
|
||||
};
|
||||
expected.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("IM")
|
||||
.putData("height", 213)
|
||||
.putData("width", 638)
|
||||
.putData("name", "roses.jpg")
|
||||
.putData("val", "<3992, 123456789012345678901234567890123456789012345678901234567890 bytes: ...>")
|
||||
.putData("mime", "image/jpeg"),
|
||||
};
|
||||
assertEquals("Reply 6 has failed", expected, actual);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFormat() {
|
||||
// --------- Format 1
|
||||
Drafty src = new Drafty("Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply.");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(0, 13, 0),
|
||||
new Drafty.Style("BR", 13,1),
|
||||
new Drafty.Style("ST", 0, 38)
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("MN")
|
||||
.putData("val", "usr123abcDE")
|
||||
};
|
||||
String actual = src.toMarkdown(false);
|
||||
String expected = "*@Alice Johnson\nThis is a reply to reply*This is a Reply -> Forward -> Reply.";
|
||||
assertEquals("Format 1 has failed", expected, actual);
|
||||
|
||||
// --------- Format 2
|
||||
|
||||
src = new Drafty("an url: https://www.example.com/abc#fragment and another www.tinode.co");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style("EM", 57, 13),
|
||||
new Drafty.Style(8, 36, 0),
|
||||
new Drafty.Style(57, 13, 1)
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
new Drafty.Entity("LN")
|
||||
.putData("url", "https://www.example.com/abc#fragment"),
|
||||
new Drafty.Entity("LN")
|
||||
.putData("url", "http://www.tinode.co")
|
||||
};
|
||||
actual = src.toMarkdown(false);
|
||||
expected = "an url: [https://www.example.com/abc#fragment](https://www.example.com/abc#fragment) "+
|
||||
"and another _[www.tinode.co](http://www.tinode.co)_";
|
||||
assertEquals("Format 2 has failed", expected, actual);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInvalid() {
|
||||
// --------- Invalid 1
|
||||
Drafty src = new Drafty("Null style element");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
null,
|
||||
new Drafty.Style("EM", 5,5)
|
||||
};
|
||||
String actual = src.toMarkdown(false);
|
||||
String expected = "Null _style_ element";
|
||||
assertEquals("Invalid 1 has failed", expected, actual);
|
||||
|
||||
// --------- Invalid 2
|
||||
src = new Drafty("Missing entity");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(8,4, 0)
|
||||
};
|
||||
actual = src.toMarkdown(false);
|
||||
expected = "Missing entity";
|
||||
assertEquals("Invalid 2 has failed", expected, actual);
|
||||
|
||||
// --------- Invalid 3
|
||||
src = new Drafty("Missing entity in the middle");
|
||||
src.fmt = new Drafty.Style[]{
|
||||
new Drafty.Style(8,4, 0),
|
||||
new Drafty.Style(15,2, 1)
|
||||
};
|
||||
src.ent = new Drafty.Entity[]{
|
||||
null,
|
||||
new Drafty.Entity("LN")
|
||||
.putData("url", "http://www.tinode.co")
|
||||
};
|
||||
actual = src.toMarkdown(false);
|
||||
expected = "Missing entity [in](http://www.tinode.co) the middle";
|
||||
assertEquals("Invalid 3 has failed", expected, actual);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package co.tinode.tinodesdk.model;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class MsgRangeTest {
|
||||
@Test
|
||||
public void testRange() {
|
||||
MsgRange r1 = new MsgRange(1, 5);
|
||||
MsgRange r2 = new MsgRange(1, 5);
|
||||
MsgRange r3 = new MsgRange(2, 4);
|
||||
MsgRange r4 = new MsgRange(3, 6);
|
||||
|
||||
assertEquals(r1, r2);
|
||||
assertNotEquals(r1, r3);
|
||||
assertNotEquals(r1, r4);
|
||||
assertNotEquals(r3, r4);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRangeEnclosing() {
|
||||
MsgRange r1 = new MsgRange(1, 5);
|
||||
MsgRange r2 = new MsgRange(4, 8);
|
||||
MsgRange r3 = new MsgRange(6, 10);
|
||||
|
||||
MsgRange e1 = MsgRange.enclosing(new MsgRange[]{r1, r2, r3});
|
||||
MsgRange e2 = MsgRange.enclosing(new MsgRange[]{r1, r3});
|
||||
MsgRange e3 = MsgRange.enclosing(new MsgRange[]{r2, r3});
|
||||
|
||||
assertEquals(new MsgRange(1, 10), e1);
|
||||
assertEquals(new MsgRange(1, 10), e2);
|
||||
assertEquals(new MsgRange(4, 10), e3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRangeNormalize() {
|
||||
MsgRange r1 = new MsgRange(1, 5);
|
||||
MsgRange r2 = new MsgRange(3, 3);
|
||||
MsgRange r3 = new MsgRange(3, -50);
|
||||
|
||||
r1.normalize();
|
||||
r2.normalize();
|
||||
r3.normalize();
|
||||
|
||||
assertEquals(new MsgRange(1, 5), r1);
|
||||
assertEquals(new MsgRange(3), r2);
|
||||
assertEquals(new MsgRange(3), r3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRangeToString() {
|
||||
MsgRange r1 = new MsgRange(1, 5);
|
||||
assertEquals("{low: 1, hi: 5}", r1.toString());
|
||||
|
||||
MsgRange r2 = new MsgRange(3);
|
||||
assertEquals("{low: 3}", r2.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRangeToRanges() {
|
||||
List<Integer> list = new ArrayList<>();
|
||||
list.add(1);
|
||||
list.add(2);
|
||||
list.add(3);
|
||||
list.add(5);
|
||||
list.add(6);
|
||||
|
||||
MsgRange[] ranges = MsgRange.toRanges(list);
|
||||
assertEquals(2, ranges.length);
|
||||
assertEquals(new MsgRange(1, 4), ranges[0]);
|
||||
assertEquals(new MsgRange(5, 7), ranges[1]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRangeToRangesEmpty() {
|
||||
List<Integer> list = new ArrayList<>();
|
||||
MsgRange[] ranges = MsgRange.toRanges(list);
|
||||
assertNull(ranges);
|
||||
|
||||
int[] arr = new int[0];
|
||||
ranges = MsgRange.toRanges(arr);
|
||||
assertNull(ranges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRangeToRangesSingle() {
|
||||
List<Integer> list = new ArrayList<>();
|
||||
list.add(1);
|
||||
|
||||
MsgRange[] ranges = MsgRange.toRanges(list);
|
||||
assertEquals(1, ranges.length);
|
||||
assertEquals(new MsgRange(1), ranges[0]);
|
||||
|
||||
int[] arr = new int[]{1};
|
||||
ranges = MsgRange.toRanges(arr);
|
||||
assertEquals(1, ranges.length);
|
||||
assertEquals(new MsgRange(1), ranges[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testTryExtending() {
|
||||
MsgRange r1 = new MsgRange(1, 5);
|
||||
assertTrue(r1.tryExtending(5));
|
||||
assertEquals(new MsgRange(1, 6), r1);
|
||||
|
||||
MsgRange r2 = new MsgRange(1, 5);
|
||||
assertFalse(r2.tryExtending(0));
|
||||
assertEquals(new MsgRange(1, 5), r2);
|
||||
|
||||
MsgRange r3 = new MsgRange(1, 5);
|
||||
assertFalse(r3.tryExtending(6));
|
||||
assertEquals(new MsgRange(1, 5), r3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCollapse() {
|
||||
MsgRange r1 = new MsgRange(1, 5);
|
||||
MsgRange r2 = new MsgRange(3, 7);
|
||||
MsgRange r3 = new MsgRange(6, 10);
|
||||
|
||||
List<MsgRange> ranges = new ArrayList<>();
|
||||
ranges.add(r1);
|
||||
ranges.add(r2);
|
||||
ranges.add(r3);
|
||||
|
||||
MsgRange[] collapsed = MsgRange.collapse(ranges.toArray(new MsgRange[]{}));
|
||||
assertEquals(1, collapsed.length);
|
||||
assertEquals(new MsgRange(1, 10), collapsed[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCollapseEmpty() {
|
||||
MsgRange[] ranges = new MsgRange[]{};
|
||||
MsgRange[] collapsed = MsgRange.collapse(ranges);
|
||||
assertEquals(0, collapsed.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCollapseSingle() {
|
||||
MsgRange r1 = new MsgRange(1, 5);
|
||||
List<MsgRange> ranges = new ArrayList<>();
|
||||
ranges.add(r1);
|
||||
|
||||
MsgRange[] collapsed = MsgRange.collapse(ranges.toArray(new MsgRange[]{}));
|
||||
assertEquals(1, collapsed.length);
|
||||
assertEquals(new MsgRange(1, 5), collapsed[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCollapseNonOverlapping() {
|
||||
MsgRange r1 = new MsgRange(1, 5);
|
||||
MsgRange r2 = new MsgRange(7, 10);
|
||||
MsgRange r3 = new MsgRange(11, 15);
|
||||
|
||||
List<MsgRange> ranges = new ArrayList<>();
|
||||
ranges.add(r1);
|
||||
ranges.add(r2);
|
||||
ranges.add(r3);
|
||||
|
||||
MsgRange[] collapsed = MsgRange.collapse(ranges.toArray(new MsgRange[]{}));
|
||||
assertEquals(3, collapsed.length);
|
||||
assertEquals(new MsgRange(1, 5), collapsed[0]);
|
||||
assertEquals(new MsgRange(7, 10), collapsed[1]);
|
||||
assertEquals(new MsgRange(11, 15), collapsed[2]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGaps() {
|
||||
MsgRange r1 = new MsgRange(1, 5);
|
||||
MsgRange r2 = new MsgRange(7, 10);
|
||||
MsgRange r3 = new MsgRange(11, 15);
|
||||
|
||||
List<MsgRange> ranges = new ArrayList<>();
|
||||
ranges.add(r1);
|
||||
ranges.add(r2);
|
||||
ranges.add(r3);
|
||||
|
||||
MsgRange[] gaps = MsgRange.gaps(ranges.toArray(new MsgRange[]{}));
|
||||
assertEquals(2, gaps.length);
|
||||
assertEquals(new MsgRange(5, 7), gaps[0]);
|
||||
assertEquals(new MsgRange(10, 11), gaps[1]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClip() {
|
||||
MsgRange r1 = new MsgRange(1, 5);
|
||||
MsgRange r2 = new MsgRange(3, 7);
|
||||
MsgRange r3 = new MsgRange(1, 10);
|
||||
|
||||
MsgRange[] clipped = MsgRange.clip(r1, r2);
|
||||
assertEquals(1, clipped.length);
|
||||
assertEquals(new MsgRange(1, 3), clipped[0]);
|
||||
|
||||
clipped = MsgRange.clip(r1, r3);
|
||||
assertEquals(0, clipped.length);
|
||||
|
||||
clipped = MsgRange.clip(r2, r1);
|
||||
assertEquals(1, clipped.length);
|
||||
assertEquals(new MsgRange(3, 5), clipped[0]);
|
||||
|
||||
clipped = MsgRange.clip(r3, r2);
|
||||
assertEquals(2, clipped.length);
|
||||
assertEquals(new MsgRange(1, 3), clipped[0]);
|
||||
assertEquals(new MsgRange(7, 10), clipped[1]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user