mirror of
https://github.com/afollestad/nock-nock.git
synced 2025-04-20 19:45:17 +00:00
The ability to disable checks for sites, resolves #8.
This commit is contained in:
parent
ef73245831
commit
8193dd017d
12 changed files with 214 additions and 129 deletions
|
@ -46,7 +46,14 @@ class ServerVH constructor(
|
|||
itemView.textStatus.setText(statusText)
|
||||
}
|
||||
|
||||
itemView.textInterval.text = model.intervalText()
|
||||
if (model.disabled) {
|
||||
itemView.textInterval.setText(R.string.checks_disabled)
|
||||
} else {
|
||||
itemView.textInterval.text = itemView.resources.getString(
|
||||
R.string.next_check_x,
|
||||
model.intervalText()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLongClick(view: View): Boolean {
|
||||
|
|
|
@ -55,7 +55,6 @@ import kotlinx.coroutines.Dispatchers.IO
|
|||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.System.currentTimeMillis
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
import kotlin.properties.Delegates.notNull
|
||||
|
@ -233,7 +232,6 @@ class AddSiteActivity : AppCompatActivity(), View.OnClickListener {
|
|||
|
||||
newModel = newModel.copy(
|
||||
checkInterval = selectedCheckInterval,
|
||||
lastCheck = currentTimeMillis() - selectedCheckInterval,
|
||||
validationMode = selectedValidationMode,
|
||||
validationContent = selectedValidationMode.validationContent()
|
||||
)
|
||||
|
@ -242,8 +240,12 @@ class AddSiteActivity : AppCompatActivity(), View.OnClickListener {
|
|||
launch(coroutineContext) {
|
||||
loadingProgress.setLoading()
|
||||
val storedModel = async(IO) { serverModelStore.put(newModel) }.await()
|
||||
checkStatusManager.cancelCheck(storedModel)
|
||||
checkStatusManager.scheduleCheck(storedModel, rightNow = true)
|
||||
|
||||
checkStatusManager.scheduleCheck(
|
||||
site = storedModel,
|
||||
rightNow = true,
|
||||
cancelPrevious = true
|
||||
)
|
||||
loadingProgress.setDone()
|
||||
|
||||
setResult(RESULT_OK)
|
||||
|
|
|
@ -131,6 +131,7 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
safeRegisterReceiver(intentReceiver, filter)
|
||||
|
||||
notificationManager.cancelStatusNotifications()
|
||||
refreshModels()
|
||||
}
|
||||
|
||||
|
@ -171,10 +172,7 @@ class MainActivity : AppCompatActivity() {
|
|||
title(R.string.options)
|
||||
listItems(R.array.site_long_options) { _, i, _ ->
|
||||
when (i) {
|
||||
0 -> {
|
||||
checkStatusManager.cancelCheck(model)
|
||||
checkStatusManager.scheduleCheck(model)
|
||||
}
|
||||
0 -> checkStatusManager.scheduleCheck(site = model, cancelPrevious = true)
|
||||
1 -> maybeRemoveSite(model) {
|
||||
adapter.remove(i)
|
||||
emptyText.showOrHide(adapter.itemCount == 0)
|
||||
|
@ -207,7 +205,7 @@ class MainActivity : AppCompatActivity() {
|
|||
)
|
||||
positiveButton(R.string.remove) {
|
||||
checkStatusManager.cancelCheck(model)
|
||||
notificationManager.cancelStatusNotifications()
|
||||
notificationManager.cancelStatusNotification(model)
|
||||
performRemoveSite(model, onRemoved)
|
||||
}
|
||||
negativeButton(android.R.string.cancel)
|
||||
|
|
|
@ -21,6 +21,7 @@ import androidx.annotation.CheckResult
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.afollestad.nocknock.BuildConfig
|
||||
import com.afollestad.nocknock.R
|
||||
|
@ -74,7 +75,6 @@ import kotlinx.coroutines.Dispatchers.IO
|
|||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.System.currentTimeMillis
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_VIEW_MODEL = "site_model"
|
||||
|
@ -228,7 +228,15 @@ class ViewSiteActivity : AppCompatActivity(),
|
|||
}
|
||||
}
|
||||
|
||||
disableChecksButton.setOnClickListener(this@ViewSiteActivity)
|
||||
disableChecksButton.showOrHide(!this.disabled)
|
||||
|
||||
doneBtn.setOnClickListener(this@ViewSiteActivity)
|
||||
doneBtn.setText(
|
||||
if (this.disabled) R.string.renable_and_save_changes
|
||||
else R.string.save_changes
|
||||
)
|
||||
|
||||
invalidateMenuForStatus()
|
||||
}
|
||||
|
||||
|
@ -245,11 +253,139 @@ class ViewSiteActivity : AppCompatActivity(),
|
|||
safeUnregisterReceiver(intentReceiver)
|
||||
}
|
||||
|
||||
override fun onClick(view: View) = when (view.id) {
|
||||
R.id.doneBtn -> performSaveChangesAndFinish()
|
||||
R.id.disableChecksButton -> maybeDisableChecks()
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.refresh -> performCheckNow()
|
||||
R.id.remove -> maybeRemoveSite()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun performCheckNow() {
|
||||
rootView.scopeWhileAttached(Main) {
|
||||
launch(coroutineContext) {
|
||||
disableChecksButton.disable()
|
||||
loadingProgress.setLoading()
|
||||
updateModelFromInput(false)
|
||||
currentModel = currentModel.copy(status = WAITING)
|
||||
displayCurrentModel()
|
||||
|
||||
async(IO) { serverModelStore.update(currentModel) }.await()
|
||||
|
||||
checkStatusManager.scheduleCheck(
|
||||
site = currentModel,
|
||||
rightNow = true,
|
||||
cancelPrevious = true
|
||||
)
|
||||
loadingProgress.setDone()
|
||||
disableChecksButton.enable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeRemoveSite() {
|
||||
MaterialDialog(this).show {
|
||||
title(R.string.remove_site)
|
||||
message(
|
||||
text = HtmlCompat.fromHtml(
|
||||
context.getString(R.string.remove_site_prompt, currentModel.name),
|
||||
FROM_HTML_MODE_LEGACY
|
||||
)
|
||||
)
|
||||
positiveButton(R.string.remove) {
|
||||
checkStatusManager.cancelCheck(currentModel)
|
||||
notificationManager.cancelStatusNotification(currentModel)
|
||||
performRemoveSite()
|
||||
}
|
||||
negativeButton(android.R.string.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performRemoveSite() {
|
||||
rootView.scopeWhileAttached(Main) {
|
||||
launch(coroutineContext) {
|
||||
loadingProgress.setLoading()
|
||||
async(IO) { serverModelStore.delete(currentModel) }.await()
|
||||
loadingProgress.setDone()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeDisableChecks() {
|
||||
MaterialDialog(this).show {
|
||||
title(R.string.disable_automatic_checks)
|
||||
message(
|
||||
text = HtmlCompat.fromHtml(
|
||||
context.getString(R.string.disable_automatic_checks_prompt, currentModel.name),
|
||||
FROM_HTML_MODE_LEGACY
|
||||
)
|
||||
)
|
||||
positiveButton(R.string.disable) {
|
||||
checkStatusManager.cancelCheck(currentModel)
|
||||
notificationManager.cancelStatusNotification(currentModel)
|
||||
performDisableChecks()
|
||||
}
|
||||
negativeButton(android.R.string.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performDisableChecks() {
|
||||
rootView.scopeWhileAttached(Main) {
|
||||
launch(coroutineContext) {
|
||||
loadingProgress.setLoading()
|
||||
currentModel = currentModel.copy(
|
||||
disabled = true,
|
||||
lastCheck = LAST_CHECK_NONE
|
||||
)
|
||||
async(IO) { serverModelStore.update(currentModel) }.await()
|
||||
loadingProgress.setDone()
|
||||
displayCurrentModel() // invalidate UI to reflect disabled state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSaveChangesAndFinish() {
|
||||
rootView.scopeWhileAttached(Main) {
|
||||
launch(coroutineContext) {
|
||||
loadingProgress.setLoading()
|
||||
if (!updateModelFromInput(true)) {
|
||||
// Validation didn't pass
|
||||
loadingProgress.setDone()
|
||||
return@launch
|
||||
}
|
||||
|
||||
async(IO) { serverModelStore.update(currentModel) }.await()
|
||||
checkStatusManager.scheduleCheck(
|
||||
site = currentModel,
|
||||
rightNow = true,
|
||||
cancelPrevious = true
|
||||
)
|
||||
|
||||
loadingProgress.setDone()
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateMenuForStatus() {
|
||||
val item = toolbar.menu.findItem(R.id.refresh)
|
||||
item.isEnabled = currentModel.status != CHECKING && currentModel.status != WAITING
|
||||
}
|
||||
|
||||
@CheckResult private fun updateModelFromInput(withValidation: Boolean): Boolean {
|
||||
currentModel = currentModel.copy(
|
||||
name = inputName.trimmedText(),
|
||||
url = inputUrl.trimmedText(),
|
||||
status = WAITING
|
||||
status = WAITING,
|
||||
disabled = false
|
||||
)
|
||||
|
||||
if (withValidation && currentModel.name.isEmpty()) {
|
||||
|
@ -281,7 +417,6 @@ class ViewSiteActivity : AppCompatActivity(),
|
|||
|
||||
currentModel = currentModel.copy(
|
||||
checkInterval = selectedCheckInterval,
|
||||
lastCheck = currentTimeMillis() - selectedCheckInterval,
|
||||
validationMode = selectedValidationMode,
|
||||
validationContent = selectedValidationMode.validationContent()
|
||||
)
|
||||
|
@ -289,91 +424,6 @@ class ViewSiteActivity : AppCompatActivity(),
|
|||
return true
|
||||
}
|
||||
|
||||
// Save button
|
||||
override fun onClick(view: View) {
|
||||
rootView.scopeWhileAttached(Main) {
|
||||
launch(coroutineContext) {
|
||||
loadingProgress.setLoading()
|
||||
if (!updateModelFromInput(true)) {
|
||||
// Validation didn't pass
|
||||
loadingProgress.setDone()
|
||||
return@launch
|
||||
}
|
||||
|
||||
async(IO) { serverModelStore.update(currentModel) }.await()
|
||||
checkStatusManager.cancelCheck(currentModel)
|
||||
checkStatusManager.scheduleCheck(currentModel, rightNow = true)
|
||||
|
||||
loadingProgress.setDone()
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.refresh -> {
|
||||
rootView.scopeWhileAttached(Main) {
|
||||
launch(coroutineContext) {
|
||||
disableChecksButton.disable()
|
||||
loadingProgress.setLoading()
|
||||
updateModelFromInput(false)
|
||||
currentModel = currentModel.copy(status = WAITING)
|
||||
displayCurrentModel()
|
||||
|
||||
async(IO) { serverModelStore.update(currentModel) }.await()
|
||||
|
||||
checkStatusManager.cancelCheck(currentModel)
|
||||
checkStatusManager.scheduleCheck(currentModel, rightNow = true)
|
||||
loadingProgress.setDone()
|
||||
disableChecksButton.enable()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
R.id.remove -> {
|
||||
maybeRemoveSite(currentModel)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun maybeRemoveSite(model: ServerModel) {
|
||||
MaterialDialog(this).show {
|
||||
title(R.string.remove_site)
|
||||
message(
|
||||
text = HtmlCompat.fromHtml(
|
||||
context.getString(R.string.remove_site_prompt, model.name),
|
||||
HtmlCompat.FROM_HTML_MODE_LEGACY
|
||||
)
|
||||
)
|
||||
positiveButton(R.string.remove) {
|
||||
checkStatusManager.cancelCheck(model)
|
||||
notificationManager.cancelStatusNotifications()
|
||||
performRemoveSite(model)
|
||||
}
|
||||
negativeButton(android.R.string.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performRemoveSite(model: ServerModel) {
|
||||
rootView.scopeWhileAttached(Main) {
|
||||
launch(coroutineContext) {
|
||||
loadingProgress.setLoading()
|
||||
async(IO) { serverModelStore.delete(model) }.await()
|
||||
loadingProgress.setDone()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateMenuForStatus() {
|
||||
val item = toolbar.menu.findItem(R.id.refresh)
|
||||
item.isEnabled = currentModel.status != CHECKING && currentModel.status != WAITING
|
||||
}
|
||||
|
||||
private fun ValidationMode.validationContent() = when (this) {
|
||||
STATUS_CODE -> null
|
||||
TERM_SEARCH -> responseValidationSearchTerm.trimmedText()
|
||||
|
|
|
@ -57,7 +57,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:fontFamily="@font/lato_light"
|
||||
android:fontFamily="@font/lato_black"
|
||||
android:singleLine="true"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="@dimen/caption_font_size"
|
||||
|
@ -71,7 +71,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/list_text_spacing"
|
||||
android:fontFamily="@font/lato"
|
||||
android:fontFamily="@font/lato_light"
|
||||
android:singleLine="true"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="@dimen/body_font_size"
|
||||
|
|
|
@ -30,13 +30,22 @@
|
|||
<string name="remove_site_prompt"><![CDATA[Remove <b>%1$s</b> from your sites?]]></string>
|
||||
<string name="remove">Remove</string>
|
||||
<string name="save_changes">Save Changes</string>
|
||||
<string name="disable_automatic_checks">Disable Automatic Checks</string>
|
||||
<string name="view_site">View Site</string>
|
||||
<string name="last_check_result">Last Check Result</string>
|
||||
<string name="next_check">Next Check</string>
|
||||
<string name="next_check_x">Next Check: %1$s</string>
|
||||
<string name="none_turned_off">None (turned off)</string>
|
||||
<string name="none">None</string>
|
||||
|
||||
<string name="disable_automatic_checks">Disable Automatic Checks</string>
|
||||
<string name="disable_automatic_checks_prompt"><![CDATA[
|
||||
Disable automatic checks for <b>%1$s</b>? The site will not be validated in the background
|
||||
until you re-enable checks for it. You can still manually perform checks by tapping the
|
||||
Refresh icon at the top of this page.
|
||||
]]></string>
|
||||
<string name="disable">Disable</string>
|
||||
<string name="renable_and_save_changes">Enable Checks & Save Changes</string>
|
||||
|
||||
<string name="refresh_status">Refresh Status</string>
|
||||
|
||||
<string name="warning_http_url">
|
||||
|
|
|
@ -11,6 +11,7 @@ import com.afollestad.nocknock.data.ServerStatus.OK
|
|||
import com.afollestad.nocknock.utilities.ext.timeString
|
||||
import java.io.Serializable
|
||||
import java.lang.System.currentTimeMillis
|
||||
import kotlin.math.max
|
||||
|
||||
const val CHECK_INTERVAL_UNSET = -1L
|
||||
const val LAST_CHECK_NONE = -1L
|
||||
|
@ -62,12 +63,10 @@ data class ServerModel(
|
|||
}
|
||||
}
|
||||
|
||||
fun intervalText() = if (checkInterval <= 0) {
|
||||
""
|
||||
} else {
|
||||
fun intervalText(): String {
|
||||
val now = currentTimeMillis()
|
||||
val nextCheck = lastCheck + checkInterval
|
||||
(nextCheck - now).timeString()
|
||||
val nextCheck = max(lastCheck, 0) + checkInterval
|
||||
return (nextCheck - now).timeString()
|
||||
}
|
||||
|
||||
fun toContentValues() = ContentValues().apply {
|
||||
|
|
|
@ -36,3 +36,5 @@ fun ServerStatus.textRes() = when (this) {
|
|||
}
|
||||
|
||||
fun Int.toServerStatus() = ServerStatus.fromValue(this)
|
||||
|
||||
fun ServerStatus.isPending() = this == WAITING || this == CHECKING
|
||||
|
|
|
@ -17,6 +17,7 @@ import com.afollestad.nocknock.data.ServerStatus.OK
|
|||
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
|
||||
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
|
||||
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
|
||||
import com.afollestad.nocknock.data.isPending
|
||||
import com.afollestad.nocknock.engine.BuildConfig.APPLICATION_ID
|
||||
import com.afollestad.nocknock.engine.db.ServerModelStore
|
||||
import com.afollestad.nocknock.notifications.NockNotificationManager
|
||||
|
@ -127,7 +128,10 @@ class CheckStatusJob : JobService() {
|
|||
notificationManager.postStatusNotification(result)
|
||||
}
|
||||
|
||||
checkStatusManager.scheduleCheck(result)
|
||||
checkStatusManager.scheduleCheck(
|
||||
site = result,
|
||||
fromFinishingJob = true
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
|
@ -146,8 +150,8 @@ class CheckStatusJob : JobService() {
|
|||
log("Updating ${site.name} (${site.url}) status to $status...")
|
||||
|
||||
val lastCheckTime =
|
||||
if (status == CHECKING) currentTimeMillis()
|
||||
else site.lastCheck
|
||||
if (status.isPending()) site.lastCheck
|
||||
else currentTimeMillis()
|
||||
val reason =
|
||||
if (status == OK) null
|
||||
else site.reason
|
||||
|
|
|
@ -38,7 +38,9 @@ interface CheckStatusManager {
|
|||
|
||||
fun scheduleCheck(
|
||||
site: ServerModel,
|
||||
rightNow: Boolean = false
|
||||
rightNow: Boolean = false,
|
||||
cancelPrevious: Boolean = false,
|
||||
fromFinishingJob: Boolean = false
|
||||
)
|
||||
|
||||
fun cancelCheck(site: ServerModel)
|
||||
|
@ -55,7 +57,6 @@ class RealCheckStatusManager @Inject constructor(
|
|||
) : CheckStatusManager {
|
||||
|
||||
companion object {
|
||||
|
||||
private fun log(message: String) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d("CheckStatusManager", message)
|
||||
|
@ -68,34 +69,43 @@ class RealCheckStatusManager @Inject constructor(
|
|||
if (sites.isEmpty()) {
|
||||
return
|
||||
}
|
||||
log("Ensuring sites have scheduled checks.")
|
||||
|
||||
sites.forEach { site ->
|
||||
val existingJob = jobScheduler.allPendingJobs
|
||||
.firstOrNull { job -> job.id == site.id }
|
||||
if (existingJob == null) {
|
||||
log("Site ${site.id} does NOT have a scheduled job, running one now.")
|
||||
scheduleCheck(site, rightNow = true)
|
||||
} else {
|
||||
log("Site ${site.id} already has a scheduled job. Nothing to do.")
|
||||
}
|
||||
}
|
||||
log("Ensuring enabled sites have scheduled checks.")
|
||||
sites.filter { !it.disabled }
|
||||
.forEach { site ->
|
||||
val existingJob = jobForSite(site)
|
||||
if (existingJob == null) {
|
||||
log("Site ${site.id} does NOT have a scheduled job, running one now.")
|
||||
scheduleCheck(site = site, rightNow = true)
|
||||
} else {
|
||||
log("Site ${site.id} already has a scheduled job. Nothing to do.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun scheduleCheck(
|
||||
site: ServerModel,
|
||||
rightNow: Boolean
|
||||
rightNow: Boolean,
|
||||
cancelPrevious: Boolean,
|
||||
fromFinishingJob: Boolean
|
||||
) {
|
||||
check(site.id != 0) { "Cannot schedule checks for jobs with no ID." }
|
||||
log("Requesting a check job for site to be scheduled: $site")
|
||||
if (cancelPrevious) {
|
||||
cancelCheck(site)
|
||||
} else if (!fromFinishingJob) {
|
||||
val existingJob = jobForSite(site)
|
||||
check(existingJob == null) {
|
||||
"Site ${site.id} already has a scheduled job, and cancelPrevious = false."
|
||||
}
|
||||
}
|
||||
|
||||
log("Requesting a check job for site to be scheduled: $site")
|
||||
val extras = PersistableBundle().apply {
|
||||
putInt(KEY_SITE_ID, site.id)
|
||||
}
|
||||
|
||||
// Note that we don't use the periodic feature of JobScheduler because it requires a
|
||||
// Note: we don't use the periodic feature of JobScheduler because it requires a
|
||||
// minimum of 15 minutes between each execution which may not be what's requested by the
|
||||
// user of this app.
|
||||
// user of the app.
|
||||
val jobInfo = jobInfo(app, site.id, CheckStatusJob::class.java) {
|
||||
setRequiredNetworkType(NETWORK_TYPE_ANY)
|
||||
if (rightNow) {
|
||||
|
@ -163,4 +173,8 @@ class RealCheckStatusManager @Inject constructor(
|
|||
CheckResult(model = site.copy(status = ERROR, reason = ex.message))
|
||||
}
|
||||
}
|
||||
|
||||
private fun jobForSite(site: ServerModel) =
|
||||
jobScheduler.allPendingJobs
|
||||
.firstOrNull { job -> job.id == site.id }
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ const val WEEK = DAY * 7
|
|||
const val MONTH = WEEK * 4
|
||||
|
||||
fun Long.timeString() = when {
|
||||
this <= 0 -> ""
|
||||
this <= 0 -> "??"
|
||||
this >= MONTH ->
|
||||
"${ceil((this.toFloat() / MONTH.toFloat()).toDouble()).toInt()}mo"
|
||||
this >= WEEK ->
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">utilities</string>
|
||||
<string name="checks_disabled">Checks Disabled</string>
|
||||
</resources>
|
||||
|
|
Loading…
Add table
Reference in a new issue