introduce Manager concept to bundle functionality like roster, blocking, …
This commit is contained in:
parent
20962554a4
commit
07c1669813
23
src/main/java/im/conversations/android/xmpp/Managers.java
Normal file
23
src/main/java/im/conversations/android/xmpp/Managers.java
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
package im.conversations.android.xmpp;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import com.google.common.collect.ClassToInstanceMap;
|
||||||
|
import com.google.common.collect.ImmutableClassToInstanceMap;
|
||||||
|
import im.conversations.android.xmpp.manager.AbstractManager;
|
||||||
|
import im.conversations.android.xmpp.manager.BlockingManager;
|
||||||
|
import im.conversations.android.xmpp.manager.BookmarkManager;
|
||||||
|
import im.conversations.android.xmpp.manager.RosterManager;
|
||||||
|
|
||||||
|
public final class Managers {
|
||||||
|
|
||||||
|
private Managers() {}
|
||||||
|
|
||||||
|
public static ClassToInstanceMap<AbstractManager> initialize(
|
||||||
|
final Context context, final XmppConnection connection) {
|
||||||
|
return new ImmutableClassToInstanceMap.Builder<AbstractManager>()
|
||||||
|
.put(BlockingManager.class, new BlockingManager(context, connection))
|
||||||
|
.put(BookmarkManager.class, new BookmarkManager(context, connection))
|
||||||
|
.put(RosterManager.class, new RosterManager(context, connection))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,6 +16,7 @@ import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import com.google.common.base.Optional;
|
import com.google.common.base.Optional;
|
||||||
import com.google.common.base.Strings;
|
import com.google.common.base.Strings;
|
||||||
|
import com.google.common.collect.ClassToInstanceMap;
|
||||||
import eu.siacs.conversations.Config;
|
import eu.siacs.conversations.Config;
|
||||||
import eu.siacs.conversations.R;
|
import eu.siacs.conversations.R;
|
||||||
import eu.siacs.conversations.crypto.XmppDomainVerifier;
|
import eu.siacs.conversations.crypto.XmppDomainVerifier;
|
||||||
|
@ -60,6 +61,7 @@ import im.conversations.android.database.CredentialStore;
|
||||||
import im.conversations.android.database.model.Account;
|
import im.conversations.android.database.model.Account;
|
||||||
import im.conversations.android.database.model.Connection;
|
import im.conversations.android.database.model.Connection;
|
||||||
import im.conversations.android.database.model.Credential;
|
import im.conversations.android.database.model.Credential;
|
||||||
|
import im.conversations.android.xmpp.manager.AbstractManager;
|
||||||
import im.conversations.android.xmpp.processor.BindProcessor;
|
import im.conversations.android.xmpp.processor.BindProcessor;
|
||||||
import im.conversations.android.xmpp.processor.IqProcessor;
|
import im.conversations.android.xmpp.processor.IqProcessor;
|
||||||
import im.conversations.android.xmpp.processor.JingleProcessor;
|
import im.conversations.android.xmpp.processor.JingleProcessor;
|
||||||
|
@ -155,6 +157,7 @@ public class XmppConnection implements Runnable {
|
||||||
private final Consumer<MessagePacket> messagePacketConsumer;
|
private final Consumer<MessagePacket> messagePacketConsumer;
|
||||||
private final BiFunction<Jid, String, Boolean> messageAcknowledgeProcessor;
|
private final BiFunction<Jid, String, Boolean> messageAcknowledgeProcessor;
|
||||||
private final Consumer<Jid> bindConsumer;
|
private final Consumer<Jid> bindConsumer;
|
||||||
|
private final ClassToInstanceMap<AbstractManager> managers;
|
||||||
private Consumer<XmppConnection> statusListener = null;
|
private Consumer<XmppConnection> statusListener = null;
|
||||||
private SaslMechanism saslMechanism;
|
private SaslMechanism saslMechanism;
|
||||||
private HashedToken.Mechanism hashTokenRequest;
|
private HashedToken.Mechanism hashTokenRequest;
|
||||||
|
@ -178,12 +181,17 @@ public class XmppConnection implements Runnable {
|
||||||
this.jinglePacketConsumer = new JingleProcessor(context, this);
|
this.jinglePacketConsumer = new JingleProcessor(context, this);
|
||||||
this.messageAcknowledgeProcessor = new MessageAcknowledgeProcessor(context, this);
|
this.messageAcknowledgeProcessor = new MessageAcknowledgeProcessor(context, this);
|
||||||
this.bindConsumer = new BindProcessor(context, this);
|
this.bindConsumer = new BindProcessor(context, this);
|
||||||
|
this.managers = Managers.initialize(context, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Account getAccount() {
|
public Account getAccount() {
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public <T extends AbstractManager> T getManager(Class<T> type) {
|
||||||
|
return this.managers.getInstance(type);
|
||||||
|
}
|
||||||
|
|
||||||
private String fixResource(final String resource) {
|
private String fixResource(final String resource) {
|
||||||
if (Strings.isNullOrEmpty(resource)) {
|
if (Strings.isNullOrEmpty(resource)) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -2778,4 +2786,27 @@ public class XmppConnection implements Runnable {
|
||||||
account.address.getDomain(), Namespace.EXTERNAL_SERVICE_DISCOVERY);
|
account.address.getDomain(), Namespace.EXTERNAL_SERVICE_DISCOVERY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public abstract static class Delegate {
|
||||||
|
|
||||||
|
protected final Context context;
|
||||||
|
protected final XmppConnection connection;
|
||||||
|
|
||||||
|
protected Delegate(final Context context, final XmppConnection connection) {
|
||||||
|
this.context = context;
|
||||||
|
this.connection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Account getAccount() {
|
||||||
|
return connection.getAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected ConversationsDatabase getDatabase() {
|
||||||
|
return ConversationsDatabase.getInstance(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T extends AbstractManager> T getManager(Class<T> type) {
|
||||||
|
return connection.managers.getInstance(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
package im.conversations.android.xmpp.manager;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
|
|
||||||
|
public class AbstractManager extends XmppConnection.Delegate {
|
||||||
|
|
||||||
|
protected AbstractManager(final Context context, final XmppConnection connection) {
|
||||||
|
super(context, connection);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
package im.conversations.android.xmpp.manager;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import com.google.common.collect.Collections2;
|
||||||
|
import eu.siacs.conversations.xmpp.stanzas.IqPacket;
|
||||||
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
|
import im.conversations.android.xmpp.model.blocking.Block;
|
||||||
|
import im.conversations.android.xmpp.model.blocking.Blocklist;
|
||||||
|
import im.conversations.android.xmpp.model.blocking.Unblock;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public class BlockingManager extends AbstractManager {
|
||||||
|
|
||||||
|
public BlockingManager(Context context, XmppConnection connection) {
|
||||||
|
super(context, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void handlePush(final Block block) {}
|
||||||
|
|
||||||
|
public void handlePush(final Unblock unblock) {}
|
||||||
|
|
||||||
|
public void fetch() {
|
||||||
|
final IqPacket iqPacket = new IqPacket(IqPacket.TYPE.GET);
|
||||||
|
iqPacket.addChild(new Blocklist());
|
||||||
|
connection.sendIqPacket(iqPacket, this::handleFetchResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleFetchResult(final IqPacket result) {
|
||||||
|
if (result.getType() != IqPacket.TYPE.RESULT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final var blocklist = result.getExtension(Blocklist.class);
|
||||||
|
if (blocklist == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final var account = getAccount();
|
||||||
|
final var items =
|
||||||
|
blocklist.getExtensions(im.conversations.android.xmpp.model.blocking.Item.class);
|
||||||
|
final var filteredItems = Collections2.filter(items, i -> Objects.nonNull(i.getJid()));
|
||||||
|
getDatabase().blockingDao().setBlocklist(account, filteredItems);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
package im.conversations.android.xmpp.manager;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
|
|
||||||
|
public class BookmarkManager extends AbstractManager {
|
||||||
|
public BookmarkManager(Context context, XmppConnection connection) {
|
||||||
|
super(context, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void fetch() {}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
package im.conversations.android.xmpp.manager;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
import com.google.common.base.Strings;
|
||||||
|
import com.google.common.collect.Collections2;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import eu.siacs.conversations.xmpp.stanzas.IqPacket;
|
||||||
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
|
import im.conversations.android.xmpp.model.roster.Item;
|
||||||
|
import im.conversations.android.xmpp.model.roster.Query;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public class RosterManager extends AbstractManager {
|
||||||
|
|
||||||
|
public RosterManager(final Context context, final XmppConnection connection) {
|
||||||
|
super(context, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void handlePush(final Query query) {
|
||||||
|
final var version = query.getVersion();
|
||||||
|
final var items = query.getExtensions(Item.class);
|
||||||
|
getDatabase().rosterDao().update(getAccount(), version, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void fetch() {
|
||||||
|
final var account = getAccount();
|
||||||
|
final var database = getDatabase();
|
||||||
|
final String rosterVersion = database.accountDao().getRosterVersion(account.id);
|
||||||
|
final IqPacket iqPacket = new IqPacket(IqPacket.TYPE.GET);
|
||||||
|
final Query rosterQuery = new Query();
|
||||||
|
iqPacket.addChild(rosterQuery);
|
||||||
|
if (Strings.isNullOrEmpty(rosterVersion)) {
|
||||||
|
Log.d(Config.LOGTAG, account.address + ": fetching roster");
|
||||||
|
} else {
|
||||||
|
Log.d(Config.LOGTAG, account.address + ": fetching roster version " + rosterVersion);
|
||||||
|
rosterQuery.setVersion(rosterVersion);
|
||||||
|
}
|
||||||
|
connection.sendIqPacket(iqPacket, this::handleFetchResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleFetchResult(final IqPacket result) {
|
||||||
|
if (result.getType() != IqPacket.TYPE.RESULT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final var query = result.getExtension(Query.class);
|
||||||
|
if (query == null) {
|
||||||
|
// No query in result means further modifications are sent via pushes
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final var account = getAccount();
|
||||||
|
final var database = getDatabase();
|
||||||
|
final var version = query.getVersion();
|
||||||
|
final var items = query.getExtensions(Item.class);
|
||||||
|
// In a roster result (Section 2.1.4), the client MUST ignore values of the c'subscription'
|
||||||
|
// attribute other than "none", "to", "from", or "both".
|
||||||
|
final var validItems =
|
||||||
|
Collections2.filter(
|
||||||
|
items,
|
||||||
|
i ->
|
||||||
|
Item.RESULT_SUBSCRIPTIONS.contains(i.getSubscription())
|
||||||
|
&& Objects.nonNull(i.getJid()));
|
||||||
|
database.rosterDao().set(account, version, validItems);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,25 +0,0 @@
|
||||||
package im.conversations.android.xmpp.processor;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import im.conversations.android.database.ConversationsDatabase;
|
|
||||||
import im.conversations.android.database.model.Account;
|
|
||||||
import im.conversations.android.xmpp.XmppConnection;
|
|
||||||
|
|
||||||
abstract class AbstractBaseProcessor {
|
|
||||||
|
|
||||||
protected final Context context;
|
|
||||||
protected final XmppConnection connection;
|
|
||||||
|
|
||||||
AbstractBaseProcessor(final Context context, final XmppConnection connection) {
|
|
||||||
this.context = context;
|
|
||||||
this.connection = connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Account getAccount() {
|
|
||||||
return connection.getAccount();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected ConversationsDatabase getDatabase() {
|
|
||||||
return ConversationsDatabase.getInstance(context);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +1,14 @@
|
||||||
package im.conversations.android.xmpp.processor;
|
package im.conversations.android.xmpp.processor;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.util.Log;
|
|
||||||
import com.google.common.base.Strings;
|
|
||||||
import com.google.common.collect.Collections2;
|
|
||||||
import eu.siacs.conversations.Config;
|
|
||||||
import eu.siacs.conversations.xmpp.Jid;
|
import eu.siacs.conversations.xmpp.Jid;
|
||||||
import eu.siacs.conversations.xmpp.stanzas.IqPacket;
|
|
||||||
import im.conversations.android.xmpp.XmppConnection;
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
import im.conversations.android.xmpp.model.blocking.Blocklist;
|
import im.conversations.android.xmpp.manager.BlockingManager;
|
||||||
import im.conversations.android.xmpp.model.roster.Item;
|
import im.conversations.android.xmpp.manager.BookmarkManager;
|
||||||
import im.conversations.android.xmpp.model.roster.Query;
|
import im.conversations.android.xmpp.manager.RosterManager;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public class BindProcessor extends AbstractBaseProcessor implements Consumer<Jid> {
|
public class BindProcessor extends XmppConnection.Delegate implements Consumer<Jid> {
|
||||||
|
|
||||||
public BindProcessor(final Context context, final XmppConnection connection) {
|
public BindProcessor(final Context context, final XmppConnection connection) {
|
||||||
super(context, connection);
|
super(context, connection);
|
||||||
|
@ -30,79 +24,18 @@ public class BindProcessor extends AbstractBaseProcessor implements Consumer<Jid
|
||||||
|
|
||||||
if (firstLogin) {
|
if (firstLogin) {
|
||||||
// TODO publish display name if this is the first attempt
|
// TODO publish display name if this is the first attempt
|
||||||
// iirc this is used when the display name is set from a certificate or something
|
// IIRC this is used when the display name is set from a certificate or something
|
||||||
}
|
}
|
||||||
|
|
||||||
database.presenceDao().deletePresences(account.id);
|
database.presenceDao().deletePresences(account.id);
|
||||||
|
|
||||||
fetchRoster();
|
getManager(RosterManager.class).fetch();
|
||||||
|
|
||||||
// TODO check feature
|
// TODO check feature before fetching
|
||||||
fetchBlocklist();
|
getManager(BlockingManager.class).fetch();
|
||||||
|
|
||||||
// TODO fetch bookmarks
|
getManager(BookmarkManager.class).fetch();
|
||||||
|
|
||||||
// TODO send initial presence
|
// TODO send initial presence
|
||||||
}
|
}
|
||||||
|
|
||||||
private void fetchRoster() {
|
|
||||||
final var account = getAccount();
|
|
||||||
final var database = getDatabase();
|
|
||||||
final String rosterVersion = database.accountDao().getRosterVersion(account.id);
|
|
||||||
final IqPacket iqPacket = new IqPacket(IqPacket.TYPE.GET);
|
|
||||||
final Query rosterQuery = new Query();
|
|
||||||
iqPacket.addChild(rosterQuery);
|
|
||||||
if (Strings.isNullOrEmpty(rosterVersion)) {
|
|
||||||
Log.d(Config.LOGTAG, account.address + ": fetching roster");
|
|
||||||
} else {
|
|
||||||
Log.d(Config.LOGTAG, account.address + ": fetching roster version " + rosterVersion);
|
|
||||||
rosterQuery.setVersion(rosterVersion);
|
|
||||||
}
|
|
||||||
connection.sendIqPacket(iqPacket, this::handleFetchRosterResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleFetchRosterResult(final IqPacket result) {
|
|
||||||
if (result.getType() != IqPacket.TYPE.RESULT) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final var query = result.getExtension(Query.class);
|
|
||||||
if (query == null) {
|
|
||||||
// No query in result means further modifications are sent via pushes
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final var account = getAccount();
|
|
||||||
final var database = getDatabase();
|
|
||||||
final var version = query.getVersion();
|
|
||||||
final var items = query.getExtensions(Item.class);
|
|
||||||
// In a roster result (Section 2.1.4), the client MUST ignore values of the c'subscription'
|
|
||||||
// attribute other than "none", "to", "from", or "both".
|
|
||||||
final var validItems =
|
|
||||||
Collections2.filter(
|
|
||||||
items,
|
|
||||||
i ->
|
|
||||||
Item.RESULT_SUBSCRIPTIONS.contains(i.getSubscription())
|
|
||||||
&& Objects.nonNull(i.getJid()));
|
|
||||||
database.rosterDao().set(account, version, validItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void fetchBlocklist() {
|
|
||||||
final IqPacket iqPacket = new IqPacket(IqPacket.TYPE.GET);
|
|
||||||
iqPacket.addChild(new Blocklist());
|
|
||||||
connection.sendIqPacket(iqPacket, this::handleFetchBlocklistResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleFetchBlocklistResult(final IqPacket result) {
|
|
||||||
if (result.getType() != IqPacket.TYPE.RESULT) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final var blocklist = result.getExtension(Blocklist.class);
|
|
||||||
if (blocklist == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final var account = getAccount();
|
|
||||||
final var items =
|
|
||||||
blocklist.getExtensions(im.conversations.android.xmpp.model.blocking.Item.class);
|
|
||||||
final var filteredItems = Collections2.filter(items, i -> Objects.nonNull(i.getJid()));
|
|
||||||
getDatabase().blockingDao().setBlocklist(account, filteredItems);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,11 +4,15 @@ import android.content.Context;
|
||||||
import com.google.common.base.Preconditions;
|
import com.google.common.base.Preconditions;
|
||||||
import eu.siacs.conversations.xmpp.stanzas.IqPacket;
|
import eu.siacs.conversations.xmpp.stanzas.IqPacket;
|
||||||
import im.conversations.android.xmpp.XmppConnection;
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
|
import im.conversations.android.xmpp.manager.BlockingManager;
|
||||||
|
import im.conversations.android.xmpp.manager.RosterManager;
|
||||||
|
import im.conversations.android.xmpp.model.blocking.Block;
|
||||||
|
import im.conversations.android.xmpp.model.blocking.Unblock;
|
||||||
import im.conversations.android.xmpp.model.roster.Query;
|
import im.conversations.android.xmpp.model.roster.Query;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public class IqProcessor extends AbstractBaseProcessor implements Consumer<IqPacket> {
|
public class IqProcessor extends XmppConnection.Delegate implements Consumer<IqPacket> {
|
||||||
|
|
||||||
public IqProcessor(final Context context, final XmppConnection connection) {
|
public IqProcessor(final Context context, final XmppConnection connection) {
|
||||||
super(context, connection);
|
super(context, connection);
|
||||||
|
@ -22,11 +26,21 @@ public class IqProcessor extends AbstractBaseProcessor implements Consumer<IqPac
|
||||||
if (type == IqPacket.TYPE.SET
|
if (type == IqPacket.TYPE.SET
|
||||||
&& connection.fromAccount(packet)
|
&& connection.fromAccount(packet)
|
||||||
&& packet.hasExtension(Query.class)) {
|
&& packet.hasExtension(Query.class)) {
|
||||||
handleRosterPush(packet.getExtension(Query.class));
|
getManager(RosterManager.class).handlePush(packet.getExtension(Query.class));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (type == IqPacket.TYPE.SET
|
||||||
|
&& connection.fromAccount(packet)
|
||||||
|
&& packet.hasExtension(Block.class)) {
|
||||||
|
getManager(BlockingManager.class).handlePush(packet.getExtension(Block.class));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (type == IqPacket.TYPE.SET
|
||||||
private void handleRosterPush(final Query query) {
|
&& connection.fromAccount(packet)
|
||||||
final String version = query.getVersion();
|
&& packet.hasExtension(Unblock.class)) {
|
||||||
|
getManager(BlockingManager.class).handlePush(packet.getExtension(Unblock.class));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TODO return feature not implemented
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import eu.siacs.conversations.xmpp.Jid;
|
||||||
import im.conversations.android.xmpp.XmppConnection;
|
import im.conversations.android.xmpp.XmppConnection;
|
||||||
import java.util.function.BiFunction;
|
import java.util.function.BiFunction;
|
||||||
|
|
||||||
public class MessageAcknowledgeProcessor extends AbstractBaseProcessor
|
public class MessageAcknowledgeProcessor extends XmppConnection.Delegate
|
||||||
implements BiFunction<Jid, String, Boolean> {
|
implements BiFunction<Jid, String, Boolean> {
|
||||||
|
|
||||||
public MessageAcknowledgeProcessor(final Context context, final XmppConnection connection) {
|
public MessageAcknowledgeProcessor(final Context context, final XmppConnection connection) {
|
||||||
|
|
Loading…
Reference in a new issue